diff --git a/AGENTS.md b/AGENTS.md index 50f147c8..bd3163ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,12 +23,14 @@ quickly in this repo. - Read this file and the `specs/` folder first; specs contain testable requirements (MUST/SHOULD/MAY). -- Key architecture: FastAPI inbox → semantic extraction - (`vultron/semantic_map.py`) → behavior dispatcher +- Key architecture: FastAPI inbox → AS2 parser + (`vultron/wire/as2/parser.py`) → semantic extraction + (`vultron/wire/as2/extractor.py`) → behavior dispatcher (`vultron/behavior_dispatcher.py`) → registered handler - (`vultron/api/v2/backend/handlers.py`). -- Follow the Handler Protocol: handlers accept a single `DispatchActivity` - param, use `@verify_semantics(...)`, and read `dispatchable.payload`. + (`vultron/api/v2/backend/handlers/`). +- Follow the Handler Protocol: handlers accept `dispatchable: DispatchEvent` + and `dl: DataLayer`, use `@verify_semantics(...)`, and read + `dispatchable.payload` (an `InboundPayload` domain type). Checklist (edit → validate → commit): @@ -49,6 +51,7 @@ uv run pytest --tb=short 2>&1 | tail -5 # Run a specific test file uv run pytest test/test_semantic_activity_patterns.py -v +# (patterns now live in vultron/wire/as2/extractor.py) # Run the demo server locally (development/demo) uv run uvicorn vultron.api.main:app --host localhost --port 7999 --reload @@ -70,7 +73,7 @@ uv run uvicorn vultron.api.main:app --host localhost --port 7999 --reload Quick pointers and gotchas: - Order matters in `SEMANTICS_ACTIVITY_PATTERNS` (place more specific patterns - first). + first); patterns live in `vultron/wire/as2/extractor.py`. - Always call `rehydrate()` on incoming activities to expand URI references before pattern matching. - Use `object_to_record()` + `dl.update(id, record)` when persisting Pydantic @@ -82,7 +85,7 @@ Examples (handler & datalayer): ```python @verify_semantics(MessageSemantics.CREATE_REPORT) -def create_report(dispatchable: DispatchActivity) -> None: +def create_report(dispatchable: DispatchEvent) -> None: payload = dispatchable.payload # rehydrate nested refs, validate, persist via datalayer.update(id, record) ``` @@ -158,11 +161,11 @@ explicit approval from the maintainers. - Side effects (I/O, persistence, network) MUST be isolated from pure logic - **Core modules MUST NOT import from application layer modules** (see `specs/code-style.md` CS-05-001) - - Core: `behavior_dispatcher.py`, `semantic_map.py`, - `semantic_handler_map.py`, `activity_patterns.py` - - Application layer: `api/v2/*` - - Use lazy imports or shared neutral modules (e.g., `types.py`, - `dispatcher_errors.py`) when dependencies exist + - Core: `vultron/core/`, `vultron/behavior_dispatcher.py` + - Wire layer: `vultron/wire/` + - Application layer: `vultron/api/v2/*` + - Use shared neutral modules (e.g., `types.py`, `dispatcher_errors.py`) + when cross-layer dependencies exist Avoid tight coupling between layers. @@ -178,7 +181,7 @@ include migration/compatibility notes and tests. This document provides guidance to AI agents working on the Vultron codebase. It supplements the Copilot instructions with implementation-specific advice. -**Last Updated:** 2026-02-20 +**Last Updated:** 2026-03-10 **For durable design insights**, see the `notes/` directory. @@ -186,22 +189,27 @@ It supplements the Copilot instructions with implementation-specific advice. ### Semantic Message Processing Pipeline -Vultron processes inbound ActivityStreams activities through a three-stage +Vultron processes inbound ActivityStreams activities through a four-stage pipeline: 1. **Inbox Endpoint** (`vultron/api/v2/routers/actors.py`): FastAPI POST - endpoint accepting activities -2. **Semantic Extraction** (`vultron/semantic_map.py`): Pattern matching on - (Activity Type, Object Type) to determine MessageSemantics -3. **Behavior Dispatch** (`vultron/behavior_dispatcher.py`): Routes to - semantic-specific handler functions + endpoint accepting activities; returns 202 immediately +2. **AS2 Parser** (`vultron/wire/as2/parser.py`): Structural validation and + deserialization of AS2 JSON +3. **Semantic Extraction** (`vultron/wire/as2/extractor.py`): Pattern matching + on (Activity Type, Object Type) to determine `MessageSemantics` +4. **Behavior Dispatch** (`vultron/behavior_dispatcher.py`): Routes to + semantic-specific handler functions via `SEMANTICS_HANDLERS` + (`vultron/api/v2/backend/handler_map.py`) **Key constraint:** Semantic extraction uses **ordered pattern matching**. When -adding patterns to `SEMANTICS_ACTIVITY_PATTERNS`, place more specific patterns -before general ones. +adding patterns to `SEMANTICS_ACTIVITY_PATTERNS` in +`vultron/wire/as2/extractor.py`, place more specific patterns before general +ones. -See `specs/dispatch-routing.md`, `specs/semantic-extraction.md`, and ADR-0007 -for complete architecture details. +See `specs/dispatch-routing.md`, `specs/semantic-extraction.md`, +`docs/adr/0007-use-behavior-dispatcher.md`, and +`docs/adr/0009-hexagonal-architecture.md` for complete architecture details. ### Hexagonal Architecture (Ports and Adapters) @@ -217,13 +225,14 @@ and domain types. Rules: AS2 types - **Core functions take domain types**: the inbound pipeline finishes parse → extract before calling into core -- **Driven adapters injected via ports**: handlers do not call `get_datalayer()` - directly (deferred in prototype — see PROTO-06-001) +- **Driven adapters injected via ports**: handlers receive `dl: DataLayer` as + a parameter; they do not call `get_datalayer()` directly See `notes/architecture-ports-and-adapters.md` for the full architecture specification and code patterns. See `notes/architecture-review.md` for the -current violation inventory (V-01 to V-12) and remediation plan. See -`specs/architecture.md` for the formal requirements (ARCH-01 to ARCH-08). +violation inventory (V-01 to V-12, all remediated as of ARCH-CLEANUP). +See `specs/architecture.md` for formal requirements (ARCH-01 to ARCH-08) and +`docs/adr/0009-hexagonal-architecture.md` for the decision rationale. ### Protocol Activity Model @@ -256,10 +265,11 @@ See `notes/activitystreams-semantics.md` for detailed discussion. All handler functions MUST: -- Accept single `DispatchActivity` parameter +- Accept `dispatchable: DispatchEvent` and `dl: DataLayer` parameters - Use `@verify_semantics(MessageSemantics.X)` decorator -- Be registered in `SEMANTIC_HANDLER_MAP` -- Access activity data via `dispatchable.payload` +- Be registered in `SEMANTICS_HANDLERS` (in + `vultron/api/v2/backend/handler_map.py`) +- Access activity data via `dispatchable.payload` (an `InboundPayload`) - Use Pydantic models for type-safe access - Follow idempotency best practices @@ -267,10 +277,10 @@ Example: ```python @verify_semantics(MessageSemantics.CREATE_REPORT) -def create_report(dispatchable: DispatchActivity) -> None: +def create_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: payload = dispatchable.payload # Access validated activity data from payload - # Implement business logic + # Use dl for persistence operations # Log state transitions ``` @@ -281,19 +291,21 @@ verification criteria. The system uses two key registries that MUST stay synchronized: -- `SEMANTIC_HANDLER_MAP` (in `vultron/semantic_handler_map.py`): Maps - MessageSemantics → handler functions -- `SEMANTICS_ACTIVITY_PATTERNS` (in `vultron/semantic_map.py`): Maps - MessageSemantics → ActivityPattern objects +- `SEMANTICS_HANDLERS` (in `vultron/api/v2/backend/handler_map.py`): Maps + `MessageSemantics` → handler functions (adapter layer) +- `SEMANTICS_ACTIVITY_PATTERNS` (in `vultron/wire/as2/extractor.py`): Maps + `MessageSemantics` → `ActivityPattern` objects (wire layer) When adding new message types: -1. Add enum value to `MessageSemantics` in `vultron/enums.py` -2. Define ActivityPattern in `vultron/activity_patterns.py` +1. Add enum value to `MessageSemantics` in `vultron/core/models/events.py` + (re-exported via `vultron/enums.py` for compatibility) +2. Define `ActivityPattern` in `vultron/wire/as2/extractor.py` 3. Add pattern to `SEMANTICS_ACTIVITY_PATTERNS` in correct order (specific before general) -4. Implement handler in `vultron/api/v2/backend/handlers.py` -5. Register handler in `SEMANTIC_HANDLER_MAP` +4. Implement handler in `vultron/api/v2/backend/handlers/` +5. Register handler in `SEMANTICS_HANDLERS` in + `vultron/api/v2/backend/handler_map.py` 6. Add tests verifying pattern matching and handler invocation ### Layer Separation (MUST) @@ -302,8 +314,10 @@ When adding new message types: immediately to backend - **Backend** (`vultron/api/v2/backend/`): Business logic; no direct HTTP concerns -- **Data Layer** (`vultron/api/v2/datalayer/`): Persistence abstraction; use - Protocol interface +- **Data Layer port** (`vultron/core/ports/activity_store.py`): `DataLayer` + Protocol definition; use this for imports in core and handlers +- **Data Layer adapter** (`vultron/api/v2/datalayer/`): TinyDB implementation; + `abc.py` is a backward-compat shim re-exporting from `core/ports/` Never bypass layer boundaries. Routers should never directly access data layer; always go through backend. @@ -366,7 +380,12 @@ See `specs/error-handling.md` for complete error hierarchy and response format. ### Naming Conventions - **ActivityStreams types**: Use `as_` prefix (e.g., `as_Activity`, `as_Actor`, - `as_type`) + `as_type`) — in the wire layer (`vultron/wire/as2/`) only +- **Core domain models**: Do NOT use `as_` prefix; for reserved-word field + name conflicts use a trailing underscore + Pydantic alias + (e.g., `object_: str = Field(alias="object")`). See CS-07-002. +- **Domain class names**: Use CVD-domain vocabulary, not wire-format parallels + (e.g., `CaseTransferOffer` not `VultronOffer`). See CS-12-001. - **Vulnerability**: Abbreviated as `vul` (not `vuln`) - **Handler functions**: Named after semantic action (e.g., `create_report`, `accept_invite_actor_to_case`) @@ -375,7 +394,7 @@ See `specs/error-handling.md` for complete error hierarchy and response format. ### Validation and Type Safety -- Prefer explicit types over inference +- Prefer explicit types over inference; avoid `Any` (see CS-11-001) - Use `pydantic.BaseModel` (v2 style) for all structured data - Never bypass validation for convenience - Use Protocol for interface definitions @@ -383,10 +402,10 @@ See `specs/error-handling.md` for complete error hierarchy and response format. - **Optional string fields MUST follow "if present, then non-empty"**: `Optional[str]` fields MUST reject empty strings. Use the shared `NonEmptyString` or `OptionalNonEmptyString` type alias from - `vultron/as_vocab/base/` when it exists (CS-08-002), or a field validator - that raises `ValueError` for `""` if the type alias is not yet available. - This pattern also applies to JSON Schemas derived from Pydantic models - (`minLength: 1`). See `specs/code-style.md` CS-08-001, CS-08-002. + `vultron/wire/as2/vocab/base/` when it exists (CS-08-002), or a field + validator that raises `ValueError` for `""` if the type alias is not yet + available. This pattern also applies to JSON Schemas derived from Pydantic + models (`minLength: 1`). See `specs/code-style.md` CS-08-001, CS-08-002. **Do NOT** add a new per-field `@field_validator` stub for empty-string rejection; instead, use or extend the shared type alias. @@ -557,15 +576,16 @@ behavior across backends (in-memory / tinydb) where reasonable. ### Adding a New Message Type -1. Add `MessageSemantics` enum value in `vultron/enums.py` -2. Define `ActivityPattern` in `vultron/activity_patterns.py` -3. Add pattern to `SEMANTICS_ACTIVITY_PATTERNS` in `vultron/semantic_map.py` - (order matters!) -4. Implement handler function in `vultron/api/v2/backend/handlers.py`: +1. Add `MessageSemantics` enum value in `vultron/core/models/events.py` +2. Define `ActivityPattern` in `vultron/wire/as2/extractor.py` +3. Add pattern to `SEMANTICS_ACTIVITY_PATTERNS` in + `vultron/wire/as2/extractor.py` (order matters!) +4. Implement handler function in `vultron/api/v2/backend/handlers/`: - Use `@verify_semantics(MessageSemantics.NEW_TYPE)` decorator - - Accept `dispatchable: DispatchActivity` parameter - - Access data via `dispatchable.payload` -5. Register in `SEMANTIC_HANDLER_MAP` in `vultron/semantic_handler_map.py` + - Accept `dispatchable: DispatchEvent` and `dl: DataLayer` parameters + - Access data via `dispatchable.payload` (`InboundPayload`) +5. Register in `SEMANTICS_HANDLERS` in + `vultron/api/v2/backend/handler_map.py` 6. Add tests: - Pattern matching in `test/test_semantic_activity_patterns.py` - Handler registration in `test/test_semantic_handler_map.py` @@ -573,32 +593,43 @@ behavior across backends (in-memory / tinydb) where reasonable. ### Key Files Map -- **Enums**: `vultron/enums.py` - All enum types including MessageSemantics -- **Patterns**: `vultron/activity_patterns.py` - Pattern definitions -- **Pattern Map**: `vultron/semantic_map.py` - Semantics → Pattern mapping -- **Handlers**: `vultron/api/v2/backend/handlers.py` - Handler implementations -- **Handler Map**: `vultron/semantic_handler_map.py` - Semantics → Handler - mapping +- **Enums**: `vultron/enums.py` - Re-exports `MessageSemantics` plus + `OfferStatusEnum`, `VultronObjectType`; `MessageSemantics` is defined in + `vultron/core/models/events.py` +- **Patterns**: `vultron/wire/as2/extractor.py` - `ActivityPattern` + definitions and `SEMANTICS_ACTIVITY_PATTERNS` dict (sole AS2→domain + mapping point) +- **Pattern Map**: `vultron/wire/as2/extractor.py` - `find_matching_semantics()` +- **Handlers**: `vultron/api/v2/backend/handlers/` - Handler implementations +- **Handler Map**: `vultron/api/v2/backend/handler_map.py` - `SEMANTICS_HANDLERS` + dict mapping `MessageSemantics` → handler functions - **Dispatcher**: `vultron/behavior_dispatcher.py` - Dispatch logic - **Inbox**: `vultron/api/v2/routers/actors.py` - Endpoint implementation -- **Triggers**: `vultron/api/v2/routers/triggers.py` - Triggerable behavior - endpoints (`POST /actors/{id}/trigger/{behavior-name}`); see +- **Triggers**: `vultron/api/v2/routers/trigger_report.py`, + `trigger_case.py`, `trigger_embargo.py` - Triggerable behavior endpoints + (`POST /actors/{id}/trigger/{behavior-name}`); see `specs/triggerable-behaviors.md` +- **Trigger Services**: `vultron/api/v2/backend/trigger_services/` - Domain + service layer for trigger endpoints - **Errors**: `vultron/errors.py`, `vultron/api/v2/errors.py` - Exception hierarchy -- **Data Layer**: `vultron/api/v2/datalayer/abc.py` - Persistence abstraction +- **Data Layer**: `vultron/core/ports/activity_store.py` - `DataLayer` Protocol + (port); `vultron/api/v2/datalayer/abc.py` is a backward-compat re-export shim - **TinyDB Backend**: `vultron/api/v2/datalayer/tinydb.py` - TinyDB implementation -- **BT Bridge**: `vultron/behaviors/bridge.py` - Handler-to-BT execution adapter -- **BT Helpers**: `vultron/behaviors/helpers.py` - DataLayer-aware BT nodes -- **BT Report**: `vultron/behaviors/report/` - Report validation tree and nodes -- **BT Prioritize**: `vultron/behaviors/report/prioritize_tree.py` - +- **BT Bridge**: `vultron/core/behaviors/bridge.py` - Handler-to-BT execution + adapter +- **BT Helpers**: `vultron/core/behaviors/helpers.py` - DataLayer-aware BT + nodes +- **BT Report**: `vultron/core/behaviors/report/` - Report validation tree and + nodes +- **BT Prioritize**: `vultron/core/behaviors/report/prioritize_tree.py` - engage_case/defer_case trees -- **BT Case**: `vultron/behaviors/case/` - Case creation tree and nodes -- **Case Event Log**: `vultron/as_vocab/objects/case_event.py` - +- **BT Case**: `vultron/core/behaviors/case/` - Case creation tree and nodes +- **Case Event Log**: `vultron/wire/as2/vocab/objects/case_event.py` - `CaseEvent` Pydantic model for trusted-timestamp event logging; use `VulnerabilityCase.record_event(object_id, event_type)` to append entries -- **Vocabulary Examples**: `vultron/as_vocab/examples/` - Canonical +- **Vocabulary Examples**: `vultron/wire/as2/vocab/examples/` - Canonical ActivityStreams activity examples (split into submodules by topic: `actor.py`, `case.py`, `embargo.py`, `note.py`, `participant.py`, `report.py`, `status.py`); use as reference for message semantics @@ -815,7 +846,7 @@ See `specs/semantic-extraction.md` SE-01-002 and ```python activity = as_Create(actor="alice", object="report-1") # Bad: strings -dispatchable = DispatchActivity(semantic_type=MessageSemantics.UNKNOWN, ...) # Bad: wrong semantic +dispatchable = DispatchEvent(semantic_type=MessageSemantics.UNKNOWN, ...) # Bad: wrong semantic ``` **Best practice**: @@ -823,7 +854,7 @@ dispatchable = DispatchActivity(semantic_type=MessageSemantics.UNKNOWN, ...) # ```python report = VulnerabilityReport(name="TEST-001", content="...") # Good: proper object activity = as_Create(actor="https://example.org/alice", object=report) # Good: full structure -dispatchable = DispatchActivity(semantic_type=MessageSemantics.CREATE_REPORT, ...) # Good: matches structure +dispatchable = DispatchEvent(semantic_type=MessageSemantics.CREATE_REPORT, ...) # Good: matches structure ``` See `specs/testability.md` TB-05-004, TB-05-005 for requirements. @@ -864,7 +895,7 @@ define one and reuse it. If a new model adds one field to an existing model, subclass the existing model. See `specs/code-style.md` CS-09-002. **`EvaluateCasePriority` is outgoing-only**: This BT node (in -`vultron/behaviors/report/nodes.py`) is for the **local actor deciding** to +`vultron/core/behaviors/report/nodes.py`) is for the **local actor deciding** to engage or defer a case. Receive-side trees (`EngageCaseBT`, `DeferCaseBT`) do **not** use it — they only record the **sender's already-made decision** by updating the sender's `CaseParticipant.participant_status[].rm_state`. diff --git a/Makefile b/Makefile index c1cdfd5d..6e565772 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,11 @@ mypy: ## Run mypy for type checking # edit $(PROJECT_HOME)/.mypy.ini to configure mypy options uv run mypy +# pyright static type checking +.PHONY: pyright +pyright: ## Run pyright for static type checking + uv run pyright + # run all linters .PHONY: lint lint: black mdlint flake8-lint mypy ## Run all linters (black, markdownlint) diff --git a/docs/adr/0009-hexagonal-architecture.md b/docs/adr/0009-hexagonal-architecture.md new file mode 100644 index 00000000..57f58749 --- /dev/null +++ b/docs/adr/0009-hexagonal-architecture.md @@ -0,0 +1,219 @@ +--- +status: accepted +date: 2026-03-10 +deciders: + - vultron maintainers +consulted: + - project stakeholders +informed: + - contributors +--- + +# Adopt Hexagonal Architecture (Ports and Adapters) for Vultron + +## Context and Problem Statement + +The Vultron codebase grew incrementally from a prototype, causing concerns +from different layers to accumulate in the same modules. ActivityStreams 2.0 +(AS2) structural types leaked into domain logic; semantic extraction was +scattered across multiple files; handler functions instantiated the +persistence layer directly; and the root `vultron/` package mixed domain +enums with wire-format enums. An architectural review (`notes/architecture-review.md`) +catalogued twelve violations of the intended separation of concerns, ranging +in severity from minor to critical. A clear architecture decision was needed +to guide remediation and prevent the same violations from re-accumulating. + +## Decision Drivers + +- **Wire format replaceability**: AS2 is the current wire format because its + vocabulary matches Vultron's domain vocabulary. That match must not collapse + the boundary between wire concerns and domain concerns. If AS2 is replaced + or extended, the change must be absorbable in the wire layer alone. +- **Testability**: Core domain logic should be testable with plain domain + objects, without constructing AS2 activities or making HTTP requests. +- **Single extraction seam**: The mapping from AS2 vocabulary to domain + semantics must live in exactly one place so that future wire format changes + have a single point of entry. +- **Dependency injection over direct instantiation**: Driven adapters + (persistence, delivery) must be injectable to support testing and alternate + backends. +- **Alignment with existing clean points**: Several existing patterns are + already architecturally sound (the `DataLayer` Protocol, `DispatchEvent` + wrapper, `MessageSemantics` enum vocabulary, FastAPI 202 + BackgroundTasks) + and should be preserved. + +## Considered Options + +- **Continue organic layering** — Impose light conventions without a formal + architecture pattern. Enforce import rules via linting. Let the structure + evolve. +- **Adopt hexagonal architecture (Ports and Adapters)** — Define explicit + layers (`core/`, `wire/`, `adapters/`), ports (abstract interfaces), and + adapters (thin translation layers). Use a separate wire layer for AS2 + parsing and semantic extraction. +- **Clean layered architecture (no hexagon)** — Impose strict top-down + layering (controller → service → repository) without the explicit + driving/driven adapter distinction. + +## Decision Outcome + +Chosen option: **Adopt hexagonal architecture (Ports and Adapters)**, because +it directly addresses the identified violations, provides the single +extraction seam the project needs, and cleanly separates the wire format from +the domain — a property that is especially important given that AS2 and the +Vultron domain vocabulary are semantically isomorphic and will otherwise +naturally collapse. + +### Layer Model + +Four layers are defined: + +```text +wire (AS2 JSON) + ↓ +AS2 Parser (vultron/wire/as2/parser.py) + ↓ +Semantic Extractor (vultron/wire/as2/extractor.py) + ↓ +Handler Dispatch (vultron/behavior_dispatcher.py) + ↓ +Domain Logic (vultron/core/ — no AS2 awareness) +``` + +The `wire/` package is structurally separate from both `core/` and +`adapters/` to reflect its status as a distinct inbound pipeline stage, not +an adapter of a particular external system. + +### Key Rules + +1. `core/` MUST NOT import from `wire/`, `adapters/`, or any framework. +2. `core/` functions MUST accept and return domain types, not AS2 types. +3. `MessageSemantics` (`SemanticIntent`) is a domain type, defined in + `core/`. +4. The semantic extractor is the **only** place AS2 vocabulary is mapped to + domain concepts. +5. Driven adapters are injected via port interfaces, never instantiated + directly. +6. The `wire/` layer is replaceable as a unit without touching `core/`. + +### Consequences + +- Good, because domain logic is testable with plain Pydantic objects and no + AS2 or HTTP dependencies. +- Good, because a future wire format (binary encoding, revised federation + standard) can be introduced by adding `wire/new_protocol/` without touching + `core/` or `adapters/`. +- Good, because the single extraction seam (`wire/as2/extractor.py`) means + new activity types require exactly one file change to add a new pattern. +- Good, because dependency injection via port interfaces enables alternative + backends (in-memory, TinyDB, PostgreSQL) to be swapped without modifying + handler code. +- Bad, because the transition from the organic prototype structure to the + hexagonal layout requires incremental refactoring across multiple modules. +- Bad, because the semantic match between AS2 vocabulary and the Vultron + domain vocabulary means some violations are subtle and may re-accumulate + without active review. + +## Validation + +- Violations V-01 through V-12 (see `notes/architecture-review.md`) + serve as the acceptance criteria: each is tracked and remediated + incrementally. +- The review checklist in `notes/architecture-ports-and-adapters.md` + (Core, Wire, Adapters, Connectors, Tests sections) is applied during code + review. +- Import boundary rules are enforced by code review and, where practical, + by `pylint`/`flake8` import order checks. + +## Violation Inventory and Remediation Status + +The architectural review identified twelve violations. Each has been assigned +to a remediation task. + +### Remediated (ARCH-1.x and ARCH-CLEANUP-x) + +| ID | Severity | Summary | Addressed by | +|------|----------|---------------------------------------------------------|-------------------| +| V-01 | Major | `MessageSemantics` co-located with AS2 enums in `enums.py` | ARCH-1.1, ARCH-CLEANUP-2 | +| V-02 | Critical | `DispatchEvent.payload` typed as `as_Activity` | ARCH-1.2 | +| V-03 | Critical | `behavior_dispatcher.py` imports AS2 type directly | ARCH-1.2 | +| V-04 | Major | `find_matching_semantics` called twice (extractor + decorator) | ARCH-1.3 | +| V-05 | Major | Pattern matching logic split across `activity_patterns.py` and `semantic_map.py` | ARCH-1.3 | +| V-06 | Major | `parse_activity` in HTTP router instead of wire layer | ARCH-1.3 | +| V-07 | Major | `inbox_handler.py` imports and uses AS2 `VOCABULARY` | ARCH-1.3 | +| V-08 | Critical | `handle_inbox_item` collapses three pipeline stages | ARCH-1.3 | +| V-09 | Major | `semantic_handler_map.py` lazy-imports adapter-layer handlers | ARCH-1.4 | +| V-10 | Major | All handlers call `get_datalayer()` directly | ARCH-1.4 | +| V-11 | Major | Handlers use `isinstance` checks against AS2 types | ARCH-CLEANUP-3 | +| V-12 | Minor | Dispatcher test constructs AS2 types for core test inputs | ARCH-CLEANUP-3 | + +Shims left at `vultron/activity_patterns.py`, `vultron/semantic_map.py`, and +`vultron/semantic_handler_map.py` for backward compatibility were removed in +ARCH-CLEANUP-1. AS2 structural enums were moved from `vultron/enums.py` to +`vultron/wire/as2/enums.py` in ARCH-CLEANUP-2. + +### Completed (PRIORITY-60 package relocation) + +Package relocations that have been completed as part of PRIORITY-60: + +- **P60-1** ✅: `vultron/as_vocab/` moved to `vultron/wire/as2/vocab/`. All + internal and external imports updated; old `vultron/as_vocab/` deleted. +- **P60-2** ✅: `vultron/behaviors/` moved to `vultron/core/behaviors/`. All + internal and external imports updated; old `vultron/behaviors/` deleted. + +### Remaining (PRIORITY-60 — in progress) + +The following structural move is deferred to PRIORITY-60 and is tracked +in `plan/IMPLEMENTATION_PLAN.md`: + +- **P60-3**: Stub the `vultron/adapters/` package per the target layout in + `notes/architecture-ports-and-adapters.md`. + +## Pros and Cons of the Options + +### Continue organic layering + +- Good: no migration cost; conventions can be enforced incrementally. +- Bad: violations re-accumulate naturally because AS2 and domain vocabulary + look identical; without a formal pattern, there is no shared vocabulary for + "this is a violation" across reviewers. +- Bad: import chains between layers remain implicit and hard to enforce. + +### Adopt hexagonal architecture (chosen) + +- Good: explicit layer vocabulary (core / wire / adapters / ports) that all + contributors can reference. +- Good: violations have a clear label and a known remediation pattern. +- Good: enables future extension points (MCP adapter, alternative wire + formats, connector plugins) without core changes. +- Neutral: incremental migration is possible; the codebase does not need to + be fully restructured before benefits are realized. +- Bad: transition work is non-trivial and must be done incrementally without + breaking the running system. + +### Clean layered architecture (no hexagon) + +- Good: familiar pattern for most Python web developers. +- Bad: does not provide the driving/driven adapter distinction needed to + support CLI, MCP, and HTTP inbox as parallel entry points. +- Bad: does not explicitly model the wire layer as separate from adapters, + which is the core architectural tension in this project. + +## More Information + +- `notes/architecture-ports-and-adapters.md` — canonical layer model, + file layout target, code patterns, and review checklist. +- `notes/architecture-review.md` — full violation inventory (V-01 to V-12) + and remediation plans (R-01 to R-06). +- `specs/architecture.md` — testable requirements derived from this decision + (ARCH-01-001 through ARCH-03-001 and beyond). +- `plan/PRIORITIES.md` — PRIORITY 50 (hexagonal architecture starting with + triggers.py), PRIORITY 60 (continue package relocation). +- `plan/IMPLEMENTATION_PLAN.md` — task-level tracking for ARCH-1.1 through + ARCH-1.4, ARCH-CLEANUP-1 through ARCH-CLEANUP-3, and P60-1 through P60-3. +- Related ADRs: + - [ADR-0005](0005-activitystreams-vocabulary-as-vultron-message-format.md) + — rationale for choosing AS2 as wire format. + - [ADR-0007](0007-use-behavior-dispatcher.md) — introduces the + `DispatchEvent` wrapper and `ActivityDispatcher` Protocol that this + architecture refines. diff --git a/docs/adr/0010-standardize-object-ids.md b/docs/adr/0010-standardize-object-ids.md new file mode 100644 index 00000000..98d1a285 --- /dev/null +++ b/docs/adr/0010-standardize-object-ids.md @@ -0,0 +1,123 @@ +--- +status: accepted +date: 2026-03-10 +deciders: + - vultron maintainers +consulted: + - project stakeholders +informed: + - contributors +--- + +# Standardize Object IDs to URI Form + +## Context and Problem Statement + +The Vultron ActivityStreams 2.0 (AS2) object model requires every object to +have an `id` field. The `generate_new_id()` utility in +`vultron/wire/as2/vocab/base/utils.py` currently returns a bare UUID-4 string +(e.g., `2196cbb2-fb6f-407c-b473-1ed8ae806578`) as the default `as_id` for +new objects. The AS2 specification and the Vultron object-IDs spec +(`specs/object-ids.md` OID-01-001) require that `id` values be full URI +identifiers. Bare UUIDs are not valid AS2 identifiers and will cause +interoperability problems once federation is implemented. + +## Decision Drivers + +- **AS2 compliance**: ActivityStreams 2.0 requires `id` to be an absolute + IRI. Bare UUIDs are not IRIs. +- **Federation readiness**: Federated delivery requires globally unique, + resolvable identifiers. Bare UUIDs cannot be resolved across systems. +- **Blackboard key safety**: py_trees blackboard keys derived from full-URI + IDs must not use the raw URI (which contains slashes). Using the last path + segment or UUID suffix as the key avoids hierarchical key-parsing issues + (OID-03-001). +- **DataLayer compatibility**: The DataLayer uses `as_id` as the primary + lookup key. Changing the default ID format is a forward-looking migration; + any existing bare-UUID records are a prototype artifact. + +## Considered Options + +1. **`urn:uuid:` prefix** — `urn:uuid:{uuid4}` (e.g., + `urn:uuid:2196cbb2-fb6f-407c-b473-1ed8ae806578`) +2. **Configurable HTTPS base URL** — `https://{base}/{type}/{uuid4}` driven + by a `VULTRON_BASE_URL` environment variable +3. **Continue using bare UUIDs** — defer the change + +## Decision Outcome + +Chosen option: **Configurable HTTPS base URL with `urn:uuid:` fallback**, +because: + +- HTTPS URLs are the canonical AS2 `id` form and are directly resolvable. +- A configurable base URL (via `VULTRON_BASE_URL` env var) allows deployment + flexibility without hard-coding a hostname. +- When `VULTRON_BASE_URL` is not set, the helper defaults to `urn:uuid:` + form, which is a valid absolute IRI and avoids any need for a running HTTP + server during development and testing. +- The `make_id()` helper in `vultron/api/v2/data/utils.py` (which already + uses HTTPS base-URL form) becomes the shared canonical ID factory for all + layers, satisfying OID-01-004. + +### Consequences + +- Good: all newly created objects carry globally unique, AS2-compliant `id` + values. +- Good: `VULTRON_BASE_URL` enables environment-specific ID namespaces + (dev / staging / production) without code changes. +- Good: `urn:uuid:` default keeps development and test setups self-contained. +- Neutral: existing bare-UUID records in prototype data stores remain usable + during the prototype phase; a migration script is not required until + production deployment (OID-04-002, PROTO-01-001). +- Bad: `generate_new_id()` now produces IDs that contain colons, which must + not be used directly as py_trees blackboard keys (use the UUID suffix + portion only — see OID-03-001). + +## Validation + +- Unit tests in `test/wire/as2/vocab/base/test_utils.py` assert that + `generate_new_id()` returns a value starting with `urn:uuid:` or a + configured HTTPS prefix. +- Unit tests in `test/api/v2/data/test_utils.py` confirm that `make_id()` + produces HTTPS-prefixed IDs and that `BASE_URL` is read from + `VULTRON_BASE_URL` when set. +- Code review: no new code should assign a bare UUID directly to `as_id` + without wrapping it in a URI prefix. + +## Pros and Cons of the Options + +### `urn:uuid:` prefix + +- Good: simple — no environment configuration required. +- Good: valid absolute IRI; accepted by AS2 parsers. +- Good: no slashes in the ID, so the full ID can be used as a path segment + without percent-encoding. +- Neutral: not directly HTTP-resolvable. +- Bad: does not support the future federation model where IDs are resolvable + URLs on actor servers. + +### Configurable HTTPS base URL + +- Good: IDs are HTTP-resolvable, matching the ActivityPub federation model. +- Good: per-environment namespacing via `VULTRON_BASE_URL`. +- Bad: IDs contain slashes; API routes must URL-encode or use query + parameters for ID-based lookups. +- Bad: requires a configured hostname to produce meaningful IDs. + +### Continue using bare UUIDs + +- Good: no code changes required. +- Bad: violates AS2 spec; blocks federation. +- Bad: violates `specs/object-ids.md` OID-01-001. + +## More Information + +- `specs/object-ids.md` — normative requirements OID-01 through OID-04. +- `notes/codebase-structure.md` — "Technical Debt: Object IDs Should Be + URL-Like, Not Bare UUIDs" section. +- `plan/IMPLEMENTATION_PLAN.md` — TECHDEBT-3. +- Related ADRs: + - [ADR-0005](0005-activitystreams-vocabulary-as-vultron-message-format.md) + — rationale for using AS2 as the wire format. + - [ADR-0009](0009-hexagonal-architecture.md) — hexagonal architecture that + separates wire format concerns from domain logic. diff --git a/docs/adr/index.md b/docs/adr/index.md index d773606d..ab44064d 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -32,6 +32,8 @@ General information about architectural decision records is available at driving_ports +driving_ports --> core +core --> driven_ports +driven_ports --> driven_adapters +``` diff --git a/docs/reference/inbox_handler.md b/docs/reference/inbox_handler.md index e7f43176..9d86effa 100644 --- a/docs/reference/inbox_handler.md +++ b/docs/reference/inbox_handler.md @@ -70,8 +70,8 @@ The inbox handler process consists of the following steps: 4. **Queue Activity**: (only if **Validate Activity** succeeds) The inbox endpoint places the activity message into an asynchronous processing call to the inbox handler function, allowing the endpoint to respond immediately while the activity is processed in the background. 5. **Acknowledge Receipt**: The inbox endpoint responds to the POST request with a 200 OK status to acknowledge receipt of the activity message. If the validation failed, it can respond with an appropriate error status (e.g., 400 Bad Request) and log the error. 6. **Extract Routing Information**: The inbox handler extracts key fields from the activity message (e.g., `type`, `object`, `to`, `inReplyTo`) to determine routing. - It creates a `DispatchActivity` header object containing the routing information and attaches the original activity message as the payload. -7. **Dispatch Activity**: The inbox handler invokes a dispatch function that routes the `DispatchActivity` object to the appropriate handler function based on the activity's semantic type. + It creates a `DispatchEvent` header object containing the routing information and attaches the original activity message as the payload. +7. **Dispatch Activity**: The inbox handler invokes a dispatch function that routes the `DispatchEvent` object to the appropriate handler function based on the activity's semantic type. ## Activity Patterns and Semantics @@ -126,24 +126,24 @@ activity message, along with fields such as `object`, `to`, and `inReplyTo`. The dispatch function uses this information to determine which handler function should process the activity. -!!! example "DispatchActivity Object" +!!! example "DispatchEvent Object" - A `DispatchActivity` object encapsulates the routing information needed by the - dispatch function. At the time of writing, the `DispatchActivity` object is defined as: + A `DispatchEvent` object encapsulates the routing information needed by the + dispatch function. At the time of writing, the `DispatchEvent` object is defined as: ```python @dataclass - class DispatchActivity: + class DispatchEvent: semantic_type: MessageSemantics activity_id: str - payload: as_Activity + payload: InboundPayload ``` ## Dispatch Function -The dispatch function uses the `semantic_type` field of the `DispatchActivity` +The dispatch function uses the `semantic_type` field of the `DispatchEvent` to look up the appropriate handler function from a mapping of `MessageSemantics` -to handler functions. The handler function is then invoked with the `DispatchActivity` +to handler functions. The handler function is then invoked with the `DispatchEvent` object as an argument. !!! example @@ -157,7 +157,7 @@ object as an argument. Protocol for dispatching activities to their corresponding handlers based on message semantics. """ - def dispatch(self, dispatchable: DispatchActivity) -> None: + def dispatch(self, dispatchable: DispatchEvent) -> None: """Dispatches an activity to the appropriate handler based on its semantic type.""" ... ``` @@ -181,9 +181,9 @@ object as an argument. Our first dispatch function implementation uses a simple direct dispatch approach with a dictionary mapping from `MessageSemantics` to handler functions. -The dispatch function looks up the `semantic_type` from the `DispatchActivity` +The dispatch function looks up the `semantic_type` from the `DispatchEvent` object in the mapping and invokes the corresponding handler function with the -`DispatchActivity` as an argument. +`DispatchEvent` as an argument. This direct dispatch implementation is straightforward and allows us to quickly begin handling activities based on their semantics. @@ -210,7 +210,7 @@ report submission, acknowledging receipt of a report, validating a report, etc. !!! example "Defining Handler Functions" Following is an example of how handler functions can be defined for each - semantic type. Each handler function takes a `DispatchActivity` object as + semantic type. Each handler function takes a `DispatchEvent` object as an argument and contains the logic for processing that specific type of activity. @@ -220,12 +220,12 @@ report submission, acknowledging receipt of a report, validating a report, etc. Protocol for behavior handler functions. """ - def __call__(self, dispatchable: DispatchActivity) -> None: ... + def __call__(self, dispatchable: DispatchEvent) -> None: ... ``` So a potential handler function for the `SUBMIT_REPORT` semantic type might look like this: ```python - def handle_submit_report(dispatchable: DispatchActivity) -> None: + def handle_submit_report(dispatchable: DispatchEvent) -> None: # logic for processing a vulnerability report submission goes here ... ``` @@ -242,8 +242,8 @@ report submission, acknowledging receipt of a report, validating a report, etc. ### Phase 2: Dispatching -- [x] Implement `DispatchActivity` dataclass to encapsulate routing information (`semantic_type`, `activity_id`, `payload`) (`vultron.behavior_dispatcher.DispatchActivity`) -- [x] Create routing logic to extract semantic information from incoming activities and construct `DispatchActivity` objects (`vultron.behavior_dispatcher.prepare_dispatch_activity`) +- [x] Implement `DispatchEvent` dataclass to encapsulate routing information (`semantic_type`, `activity_id`, `payload`) (`vultron.behavior_dispatcher.DispatchEvent`) +- [x] Create routing logic to extract semantic information from incoming activities and construct `DispatchEvent` objects (`vultron.behavior_dispatcher.prepare_dispatch_activity`) - [x] Define `ActivityDispatcher` Protocol interface for pluggable dispatch implementations (`vultron.behavior_dispatcher.ActivityDispatcher`) - [x] Implement direct dispatch function using dictionary mapping from `MessageSemantics` to handler functions (`vultron.behavior_dispatcher.DirectActivityDispatcher`) diff --git a/notes/README.md b/notes/README.md index cdefaf17..2a165146 100644 --- a/notes/README.md +++ b/notes/README.md @@ -19,16 +19,17 @@ Explanation) and an implementation workflow for authoring technical docs. | `bt-fuzzer-nodes.md` | Behavior tree external touchpoints based on BT simulator fuzzing, commentary on automation potential | | `bt-integration.md` | Behavior tree design decisions, py_trees patterns, simulation-to-prototype strategy | | `case-state-model.md` | VFD/PXA case state hypercube, potential actions, measuring CVD quality, case object docs vs implementation, participant-specific vs participant-agnostic state distinction; CaseStatus/ParticipantStatus append-only history; CaseEvent model for trusted timestamps (SC-PRE-1); actor-to-participant index design (SC-PRE-2); pre-case event backfill design; multi-vendor case state action rule considerations | -| `codebase-structure.md` | Module reorganization candidates, enum refactoring, API layer architecture, code documentation strategy, demo script lifecycle logging (`demo_step`/`demo_check`) | +| `codebase-structure.md` | Module reorganization candidates, enum refactoring (with target `enums.py` layout and migration path for `vultron/bt`/`vultron/case_states` enums), splitting `vultron_types.py` into individual modules, `CVDRoles` StrEnum design decision, API layer architecture (`api/v1` as thin admin adapter, `api/v2` as AS2-semantic adapter), code documentation strategy, demo script lifecycle logging (`demo_step`/`demo_check`), technical debt items (TECHDEBT-11 test dir layout, TECHDEBT-12 deprecated HTTP status constant) | | `demo-future-ideas.md` | Extended multi-actor demo scenarios: Two-Actor (Finder + Vendor), Three-Actor (Finder + Vendor + Coordinator), and MultiParty (ownership transfer + expanded participants) | | `diataxis-framework.md` | Diátaxis documentation framework: Tutorials, How‑to, Reference, Explanation, Compass, and implementation workflow | | `documentation-strategy.md` | Docs chronology and trust levels, process models, formal protocol, behavior simulator reference, Do Work behaviors, ISO crosswalks | -| `domain-model-separation.md` | Wire/Domain/Persistence layer separation, current coupling in `VulnerabilityCase`, per-actor DataLayer isolation options (including MongoDB production path), architectural direction and recommended next steps | +| `domain-model-separation.md` | Wire/Domain/Persistence layer separation; current coupling in `VulnerabilityCase`; per-actor DataLayer isolation options (including MongoDB production path); `FooActivity` vs `FooEvent` naming convention; discriminated domain event hierarchy design (P65-3a/P65-3b); P65-6a: `extract_intent()` returning discriminated union of `VultronEvent` subclasses; outbound event design questions (P65-6); architectural direction and recommended next steps | | `do-work-behaviors.md` | Scope of "do work" BT behaviors: out-of-scope, not-implementable, partially-implementable items; reporting behavior notes; embargo policy prior art; future VulnerabilityDisclosurePolicy wrapper concept | | `docker-build.md` | Project-specific Docker build observations, dependency layer caching, image content scoping, general build performance checklist | | `encryption.md` | Encryption implementation notes: public-key discovery, decryption placement, outgoing strategies, key rotation, and implementation guidance | | `federation_ideas.md` | Federation design: AS2 as vocabulary (not full ActivityPub), actor/inbox/outbox model, case object model, ownership, relay pattern, journal vs. delivery log, mirror consistency, instance trust, peering handshake, delivery architecture, connector plugins, open questions | | `triggerable-behaviors.md` | Design notes for PRIORITY 30 triggerable behaviors: trigger scope, endpoint schema, candidate behaviors (RM/EM), relationship to actor independence; resolved design decisions on `RedactedVulnerabilityCase`, per-participant embargo acceptance, and `reject-report` note requirement | +| `use-case-behavior-trees.md` | Relationship between use cases, domain logic, and behavior trees; proposed module layout; conceptual layering (Driver → Dispatcher → Use Case → BT → Domain Model); protocol activity-to-use-case mapping | ## Conventions diff --git a/notes/activitystreams-semantics.md b/notes/activitystreams-semantics.md index 028100f9..5a39f4af 100644 --- a/notes/activitystreams-semantics.md +++ b/notes/activitystreams-semantics.md @@ -125,8 +125,8 @@ The examples MUST be kept up to date as the vocabulary evolves. When adding a new vocabulary type or message semantic, add a corresponding example to `vocab_examples.py`. -**Cross-references**: `vultron/activity_patterns.py`, `vultron/enums.py`, -`vultron/as_vocab/`. +**Cross-references**: `vultron/wire/as2/extractor.py`, +`vultron/core/models/events.py`, `vultron/wire/as2/vocab/`. --- diff --git a/notes/architecture-ports-and-adapters.md b/notes/architecture-ports-and-adapters.md index 69f01a8f..a17515aa 100644 --- a/notes/architecture-ports-and-adapters.md +++ b/notes/architecture-ports-and-adapters.md @@ -4,7 +4,7 @@ This project follows **Hexagonal Architecture** (also called Ports and Adapters). The core domain logic is completely isolated from the outside world. -All external systems interact with the core through defined boundaries called +All external systems interact with the core through defined boundaries called **ports**, via thin translation layers called **adapters**. Additionally, this project has a **wire format layer** that sits outside the @@ -22,13 +22,13 @@ The case management domain was designed first, with its own semantic vocabulary (cases open, participants join, ownership transfers, etc.). When looking for a wire format for federation, Activity Streams 2.0 was found to have a **1:1 semantic match** with the domain vocabulary. AS2 was adopted as the wire -format on that basis. +format on that basis. This is important context: AS2 was chosen *because* it matched the domain, not the other way around. The domain does not depend on AS2. AS2 is a wire format that happens to express the same concepts. -See `notes/federation_ideas.md` for more on the +See `notes/federation_ideas.md` for more on the distinction between the use of AS2 vocabulary and ActivityPub the protocol. ### Why the boundary still matters @@ -56,7 +56,7 @@ boundary. ### Inbound -``` +```text 1. AS2 JSON (wire) ↓ 2. AS2 Parser @@ -79,7 +79,7 @@ boundary. ### Outbound -``` +```text 1. Domain Logic Operates on Case, CaseActor, Participant, etc. Emits domain events. @@ -94,11 +94,11 @@ boundary. ### The MessageSemantics enum -`MessageSemantics` (`vultron.enums.MessageSemantics`) is a **domain type**, not a +`MessageSemantics` (`vultron.enums.MessageSemantics`) is a **domain type**, not a wire type. Its values are the authoritative vocabulary of what can happen in the system, expressed as the domain understands them. The fact that each value maps -(`vultron.semantic_map.SEMANTICS_ACTIVITY_PATTERNS`) to an AS2 pattern +(`vultron.semantic_map.SEMANTICS_ACTIVITY_PATTERNS`) to an AS2 pattern (`vultron.activity_patterns.ActivityPattern`) is an implementation detail of the semantic extractor, not part of the domain definition. @@ -111,7 +111,7 @@ where wire format changes would be absorbed. ## The Hexagon -``` +```text ┌──────────────────────────────────────────┐ │ │ [CLI] ───────────────►│ │──────────► [Activity Store] @@ -156,7 +156,7 @@ parser and semantic extractor inline before handing off to the core. See - **Activity store** — PostgreSQL/JSONB or EventStoreDB - **Delivery queue** — NATS JetStream or Celery+Redis - **Outbound HTTP** — HTTPS POST to peer instance inboxes (httpx, mTLS) -- **DNS resolver** — (Potential future, not needed in prototype and as yet +- **DNS resolver** — (Potential future, not needed in prototype and as yet undecided in PROD) DNS TXT lookup for instance trust anchors ### Connector adapters (bidirectional — tracker plugins) @@ -177,10 +177,17 @@ pipeline. ## File Layout -This is a proposed file layout that reflects the architecture. Since we are -starting from a codebase not originally laid out this way, some refactoring -will be needed to achieve this structure. Key principles to follow during -that refactoring: +This layout describes the target architecture after hexagonal refactoring. +The following structural moves are complete (as of P60-1 and P60-2): + +- `vultron/as_vocab/` → `vultron/wire/as2/vocab/` ✅ (P60-1) +- `vultron/behaviors/` → `vultron/core/behaviors/` ✅ (P60-2) + +The following move is still pending: + +- `vultron/adapters/` package structure stub (P60-3) + +Key principles in force during further refactoring: - The `core/` package contains only domain logic and types. No AS2 imports, no framework imports, no external system imports. @@ -189,22 +196,29 @@ that refactoring: - The `adapters/` package contains only thin translation layers. No domain logic, no AS2 parsing, no semantic extraction. Just translation and dispatch. -``` +```text vultron/ ├── core/ │ ├── models/ │ │ ├── case.py # Case, CaseActor, Participant, JournalEntry -│ │ ├── events.py # SemanticIntent enum, CaseEvent types +│ │ ├── events.py # MessageSemantics enum, InboundPayload, domain event types │ │ ├── federation.py # Instance, PeeringRecord │ │ └── primitives.py # Shared value types │ │ +│ ├── behaviors/ # ✅ Moved from vultron/behaviors/ (P60-2) +│ │ ├── bridge.py # Handler-to-BT execution adapter +│ │ ├── helpers.py # DataLayer-aware BT nodes +│ │ ├── report/ # Report validation tree and nodes +│ │ └── case/ # Case creation tree and nodes +│ │ +│ ├── use_cases/ # (stub — P60-3 extension point) +│ │ └── __init__.py # Incoming port: domain use-case callables +│ │ │ ├── services/ │ │ ├── case.py # Case lifecycle: open, transfer, resolve │ │ ├── journal.py # Append, hash chaining, sequence management │ │ ├── relay.py # Fan-out logic, relay construction (domain side) -│ │ ├── mirror.py # Mirror consistency, gap detection -│ │ ├── peering.py # Instance trust, handshake logic -│ │ └── signing.py # Signing and verification (domain logic) +│ │ └── peering.py # Instance trust, handshake logic │ │ │ ├── ports/ │ │ ├── activity_store.py # Abstract interface: store/fetch events @@ -214,17 +228,18 @@ vultron/ │ └── errors.py # CaseNotFound, UnauthorizedParticipant, etc. │ ├── wire/ -│ ├── as_vocab/ -│ │ ├── types.py # AS2 Pydantic types (structural, no domain logic) -│ │ ├── parser.py # Deserialize AS2 JSON → AS2 types -│ │ ├── extractor.py # AS2 types → SemanticIntent (the mapping seam) -│ │ └── serializer.py # Domain events → AS2 types → JSON +│ └── as2/ # ✅ as_vocab moved here (P60-1) +│ ├── vocab/ # AS2 Pydantic types (structural, no domain logic) +│ ├── enums.py # AS2 structural enums +│ ├── parser.py # Deserialize AS2 JSON → AS2 types +│ ├── extractor.py # AS2 types → MessageSemantics + InboundPayload +│ └── serializer.py # Domain events → AS2 types → JSON │ └── (future_protocol)/ # Placeholder: alternative wire formats slot in here │ -├── adapters/ +├── adapters/ # (stub pending — P60-3) │ ├── driving/ │ │ ├── cli.py -│ │ ├── mcp_server.py +│ │ ├── mcp_server.py # MCP server adapter for AI agent tool calls │ │ ├── http_inbox.py # FastAPI endpoint → wire/as2 pipeline → core │ │ └── shared_inbox.py │ │ @@ -253,12 +268,34 @@ its status as a distinct layer, not an adapter of a particular external system. --- +## Design Note: Use Cases as Incoming Ports + +The handler functions in `vultron/api/v2/backend/handlers/` (e.g., +`create_report`, `submit_report`, `engage_case`, `defer_case`, `accept_invite`) +are the natural "use cases" of the hexagonal architecture — they represent +the domain's incoming ports. Currently they are defined in the adapter layer +(`api/v2/`), which couples them to the HTTP/AS2 delivery mechanism. + +The `vultron/core/use_cases/` stub package is reserved for the future move of +these callables into the core, so that any driving adapter (HTTP inbox, CLI, +MCP server) can invoke them without depending on the wire format or HTTP +framework. The key design challenge is that each use case will need to be +expressible independently of `MessageSemantics` (which is the AS2-derived +routing key), so that adapters can call use cases directly with domain +arguments. + +**Current state:** `vultron/core/use_cases/__init__.py` is an empty stub. +Use cases still live in `vultron/api/v2/backend/handlers/`. The migration to +`core/use_cases/` is a P60-3+ task. + +--- + ## Code Patterns Following are notional examples of how the layers interact, to clarify the -patterns and boundaries. Note that these examples do not necessarily reflect -the implemented codebase or the correct internal logic for individual -functions and methods. They are just meant to illustrate architectural +patterns and boundaries. Note that these examples do not necessarily reflect +the implemented codebase or the correct internal logic for individual +functions and methods. They are just meant to illustrate architectural patterns like import boundaries. ### Inbound pipeline (HTTP inbox → core) @@ -331,7 +368,7 @@ def serialize_event(event: CaseEvent, signing_key: ...) -> AS2Activity: # core/services/case.py from vultron.core.models.case import Case, CaseActor from vultron.core.models.events import SemanticIntent, CaseEvent -from vultron.core.ports.delivery_queue import DeliveryQueue +from vultron.core.use_cases.delivery_queue import DeliveryQueue async def handle_report_offer(payload: InboundPayload, @@ -425,7 +462,7 @@ in `core/`, a boundary has been violated. - [ ] Plugins translate only — no business logic - [ ] Discovered via entry points, not hardcoded imports -**Tests** +**Tests** - [ ] Core tests use domain types directly — no AS2, no HTTP - [ ] Wire tests verify parsing and extraction independently of domain logic diff --git a/notes/architecture-review.md b/notes/architecture-review.md index 73f826c5..7cebd33f 100644 --- a/notes/architecture-review.md +++ b/notes/architecture-review.md @@ -1,150 +1,490 @@ # Architectural Review: Ports and Adapters Adherence -Review against `notes/architecture-ports-and-adapters.md`. +Review against `notes/architecture-ports-and-adapters.md` and +`specs/architecture.md`. + +> **Status (2026-03-11, updated):** The original 12 violations (V-01 through +> V-12) were claimed as fully remediated through ARCH-1.1–ARCH-1.4 and +> ARCH-CLEANUP-1 through ARCH-CLEANUP-3. However, a fresh review of the +> current codebase reveals that several remediations are **incomplete or +> regressed**, and a new class of violations has been introduced in the +> `vultron/core/behaviors/` package added as part of the same refactoring. +> Violations V-03, V-02/V-11 (generalised), and V-10 have active regressions. +> New violations V-13 through V-21 are documented below. +> +> **Further update (2026-03-11, P65-1, P65-2, P65-5 complete):** +> V-13 and V-14 are **fully resolved** (P65-1: `DataLayer` moved to +> `core/ports/activity_store.py`). V-10-R is **fully resolved** (P65-2: +> lifespan-managed DataLayer injection in `inbox_handler.py`). V-15, V-16, +> and V-18 are **partially resolved** (P65-5: `object_to_record` and +> adapter-layer `OfferStatus` imports removed from core BT nodes; AS2 wire +> type imports remain — addressed in P65-6). R-08 is complete. +> +> **Further update (2026-03-11, P65-3 complete):** +> V-02-R and V-11-R are **fully resolved** (P65-3: `raw_activity: Any` removed +> from `InboundPayload`; 13 typed domain string fields added; `extract_intent()` +> added to `wire/as2/extractor.py` as the sole AS2→domain mapping point; all +> 7 handler files updated to read from `InboundPayload` fields only; opaque +> `wire_activity`/`wire_object` fields added to `DispatchActivity` for +> adapter-layer AS2 object persistence). V-21 is **fully resolved** as a side +> effect of P65-3 (`.model_dump_json()` on `raw_activity` in `dispatch()` +> removed). V-20 is **fully resolved** as a side effect of P65-2 (lazy +> `SEMANTICS_HANDLERS` import removed; `handler_map` is now a required +> constructor argument). V-03-R remains — `behavior_dispatcher.py` still +> imports `extract_intent` from the wire layer; addressed by P65-4. +> +> **Further update (2026-03-11, P65-4, P65-6b, P65-7 complete — all +> violations resolved):** +> V-03-R is **fully resolved** (P65-4: `extract_intent()` call moved upstream +> into `inbox_handler.py`; both wire-layer imports dropped from +> `behavior_dispatcher.py`; `prepare_for_dispatch()` relocated to the adapter +> layer). V-15, V-16, V-17, V-18, and V-19 are **fully resolved** (P65-6b: +> domain types from `vultron.core.models.vultron_types` replace all AS2 wire +> type imports in `core/behaviors/report/nodes.py`, `case/nodes.py`, +> `report/policy.py`, and `case/create_tree.py`). V-22 and V-23 are **fully +> resolved** (P65-7: all core BT test files updated to use domain type +> fixtures; `test_behavior_dispatcher.py` no longer imports wire types). +> **All violations V-01 through V-23 are now fully resolved.** --- ## 1. Violations -### V-01 — `vultron/enums.py`, entire file +### Active Regressions (Previously Marked Remediated) — All Resolved ✅ -**Rule:** Rule 3 (SemanticIntent is a domain type, defined in core) +--- + +### V-03-R — ✅ `vultron/behavior_dispatcher.py`, line 10 (RESOLVED P65-4) + +**Rule:** Rule 1 (core has no wire format imports) +**Severity:** Critical +**Claimed remediated by:** ARCH-1.2 +**Resolved by:** P65-4 + +After P65-3, `behavior_dispatcher.py` line 10 imported both `extract_intent` +and `find_matching_semantics` from `vultron.wire.as2.extractor`. The +`prepare_for_dispatch()` helper called `extract_intent()` to determine +semantic type before dispatch, creating a direct core→wire dependency. + +**Resolved (P65-4):** + +1. The `extract_intent()` call was moved upstream into `inbox_handler.py` + (the adapter layer); semantic extraction now happens entirely in the + adapter before calling into the dispatcher. +2. Both wire-layer imports (`extract_intent`, `find_matching_semantics`) + dropped from `behavior_dispatcher.py`. +3. `prepare_for_dispatch()` relocated to the adapter layer + (`inbox_handler.py`). +4. The `test_prepare_for_dispatch_*` test in `test/test_behavior_dispatcher.py` + moved alongside `prepare_for_dispatch` to the adapter-layer test location. + +--- + +### V-02-R — ✅ `vultron/core/models/events.py`, `InboundPayload.raw_activity` (RESOLVED P65-3) + +**Rule:** Rule 5 (core functions take and return domain types) +**Severity:** Critical +**Claimed remediated by:** ARCH-1.2 + +The ARCH-1.2 remediation introduced `InboundPayload` as a domain type to +replace `as_Activity` in `DispatchActivity.payload`. However, +`InboundPayload` included a `raw_activity: Any` field that carried the original +`as_Activity` wire object verbatim into the domain layer. Every handler +accessed AS2-specific attributes (`.as_object`, `.as_id`, `.as_type`, `.actor`) +via this field. + +**Resolved (P65-3):** `raw_activity` removed from `InboundPayload`. 13 typed +domain string fields added. `extract_intent()` in `wire/as2/extractor.py` +populates all fields. Opaque `wire_activity`/`wire_object` fields added to +`DispatchActivity` (adapter-layer type, not `InboundPayload`) for persistence +use only. + +--- + +### V-11-R — ✅ All handlers in `vultron/api/v2/backend/handlers/*.py` (RESOLVED P65-3) + +**Rule:** Rule 5 (core functions take and return domain types) **Severity:** Major +**Claimed remediated by:** ARCH-CLEANUP-3 + +ARCH-CLEANUP-3 removed `isinstance` checks but handlers still unpacked +`dispatchable.payload.raw_activity` and inspected AS2 attributes on the result. -`MessageSemantics` is a domain enum and must live in `core/models/events.py`. It -currently shares a file with AS2 structural enums (`as_ObjectType`, -`as_TransitiveActivityType`, `as_IntransitiveActivityType`, `as_ActorType`, -`as_AllObjectTypes`, and the merge helper). Placing domain vocabulary alongside -wire-format vocabulary in the same module allows any importer to access both -without noticing the layer crossing, and it signals to future maintainers that -these concerns are interchangeable. +**Resolved (P65-3):** All 7 handler files updated to read from `InboundPayload` +domain fields only. `wire_activity`/`wire_object` fields on `DispatchActivity` +are opaque adapter-layer fields; handlers access them only for AS2 object +persistence (not for domain logic decisions). --- -### V-02 — `vultron/types.py`, `DispatchActivity.payload: as_Activity` +### V-10-R — `vultron/api/v2/backend/inbox_handler.py`, module-level datalayer (regression) + +**Rule:** Rule 6 (driven adapters injected via ports) +**Severity:** Major +**Claimed remediated by:** ARCH-1.4 -**Rule:** Rule 5 (core functions take and return domain types) +Handlers were updated to receive `dl: DataLayer` as a parameter. However, +`inbox_handler.py` lines 32–33 read: + +```python +DISPATCHER = get_dispatcher(handler_map=SEMANTICS_HANDLERS, dl=get_datalayer()) +``` + +The concrete `TinyDbDataLayer` is instantiated at module **import time**, +before any request arrives. Each dispatch call then overwrites the reference +again (`DISPATCHER.dl = get_datalayer()`, line 47). The port is never injected +from outside; it is pulled directly from the concrete implementation. Handler +injection is correct in form (handlers receive `dl`) but the dispatcher that +calls them still resolves its DataLayer internally on every dispatch. + +--- + +### New Violations (Introduced in `vultron/core/behaviors/`) — All Resolved ✅ + +--- + +### V-13 — ✅ `vultron/core/behaviors/bridge.py` (RESOLVED P65-1) + +**Rule:** Rule 2 (core has no framework imports) **Severity:** Critical -`DispatchActivity` is the data carrier passed from the extractor into the -dispatcher and then into every handler. Its `payload` field is typed as -`as_Activity` — an AS2 structural type from `vultron.as_vocab`. This means the -entire dispatch chain, including all handler functions, is contractually required -to receive and inspect an AS2 object. The architecture requires that the inbound -pipeline complete its work (parse → extract) before core is invoked. Because -`payload` carries an AS2 type, that handoff never actually happens. +`from vultron.api.v2.datalayer.abc import DataLayer` — a core module imports +a type from the adapter layer (`api/v2/datalayer/`). `DataLayer` is the port +interface; by architecture it should be defined in `core/ports/` (as +`core/ports/activity_store.py` or equivalent). Importing it from `api/v2/` +makes the core depend on the adapter package tree, violating the principle that +the core knows nothing about adapters. + +**Resolved:** `DataLayer` Protocol moved to `vultron/core/ports/activity_store.py` +(P65-1). `bridge.py` now imports from `core/ports/`. The old location +(`api/v2/datalayer/abc.py`) is a backward-compat re-export shim. --- -### V-03 — `vultron/behavior_dispatcher.py`, lines 9 and 17–38 +### V-14 — ✅ `vultron/core/behaviors/helpers.py` (RESOLVED P65-1) -**Rules:** Rule 1 (core has no wire format imports), Rule 5 (core takes domain -types) +**Rule:** Rule 2 (core has no framework imports) +**Severity:** Critical + +```python +from vultron.api.v2.datalayer.abc import DataLayer +from vultron.api.v2.datalayer.db_record import Record +``` + +Same violation as V-13 but also imports `Record`, which is a +persistence-layer data type specific to the TinyDB backend adapter. `Record` +is not a domain abstraction; it is an adapter implementation detail. Core +BT helper nodes should not reference persistence record formats. + +**Resolved:** Both imports removed (P65-1/P65-5). `helpers.py` now imports +`DataLayer` from `core/ports/activity_store` and uses a `StorableRecord` +domain type instead of the adapter-layer `Record`. The `save_to_datalayer` +helper constructs `StorableRecord` from domain objects without referencing +`object_to_record`. + +--- + +### V-15 — ✅ `vultron/core/behaviors/report/nodes.py` (RESOLVED P65-6b) + +**Rule:** Rule 1 (core has no wire format imports), Rule 2 (core has no +framework imports) **Severity:** Critical -`behavior_dispatcher.py` is positioned as a core module — it houses the -`ActivityDispatcher` Protocol and the `DispatcherBase` class — but it imports -directly from the wire layer: +```python +from vultron.api.v2.data.status import (OfferStatus, ReportStatus, ...) +from vultron.api.v2.datalayer.db_record import object_to_record +from vultron.wire.as2.vocab.activities.case import CreateCase as as_CreateCase +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +``` + +A core module imported both AS2 vocabulary types (`as_CreateCase`, +`VulnerabilityCase`) and adapter-layer utilities (`object_to_record`, +`OfferStatus`). The BT node constructing a `CreateCase` AS2 activity and +calling `object_to_record` was doing AS2 serialization and persistence +formatting inside the core behavior tree layer. + +**Partially resolved (P65-5):** `object_to_record` and `OfferStatus` imports +removed; replaced by `save_to_datalayer` helper using `StorableRecord` domain +type. The AS2 wire type imports (`as_CreateCase`, `VulnerabilityCase`) remained +and were addressed in P65-6b. + +**Fully resolved (P65-6b):** All remaining AS2 wire type imports replaced with +domain types from `vultron.core.models.vultron_types`. Core BT nodes no longer +reference any wire-layer vocabulary types. + +--- + +### V-16 — ✅ `vultron/core/behaviors/report/nodes.py` (RESOLVED P65-6b) + +**Rule:** Rule 1, Rule 2 +**Severity:** Critical ```python -from vultron.as_vocab.base.objects.activities.base import as_Activity +from vultron.api.v2.datalayer.db_record import object_to_record +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus ``` -`prepare_for_dispatch(activity: as_Activity)` accepts an AS2 structural type, -meaning the dispatcher's entry point is defined in terms of the wire format. -A future wire format replacement would require changes to this core module, -which Rule 8 explicitly prohibits. +Lazy imports inside an `update()` method. These were the same violations as +V-15 deferred to runtime via local imports. Per the coding rules in +`AGENTS.md`, local imports are a code smell indicating a circular dependency +that should be refactored away, not hidden. + +**Partially resolved (P65-5):** `object_to_record` lazy import removed. +`ParticipantStatus` wire-layer local import inside +`_find_and_update_participant_rm` remained and was addressed in P65-6b. + +**Fully resolved (P65-6b):** `ParticipantStatus` wire-layer import replaced +with a domain type from `vultron.core.models.vultron_types`. No wire-layer +local imports remain in core BT nodes. --- -### V-04 — `vultron/semantic_map.py` re-invoked from `vultron/api/v2/backend/handlers/_base.py` (line 30) +### V-17 — ✅ `vultron/core/behaviors/report/policy.py`, lines 36–37 (RESOLVED P65-6b) -**Rule:** Rule 4 (the semantic extractor is the only AS2-to-domain mapping -point) +**Rule:** Rule 1 (core has no wire format imports) +**Severity:** Critical + +```python +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import VulnerabilityReport +``` + +The core policy module used AS2 `VulnerabilityCase` and `VulnerabilityReport` +as its domain types. These are wire-layer types. The policy module's +`validate()` and `should_engage()` method signatures took `VulnerabilityReport` +and `VulnerabilityCase` — wire types — as parameters, meaning the core +boundary logic was expressed in terms of the wire format, not the domain. + +**Resolved (P65-6b):** Both AS2 wire type imports replaced with domain types +from `vultron.core.models.vultron_types`. Policy method signatures now use +`VultronReport` and `VultronCase` domain types. + +--- + +### V-18 — ✅ `vultron/core/behaviors/case/nodes.py` (RESOLVED P65-6b) + +**Rule:** Rule 1, Rule 2 +**Severity:** Critical + +```python +from vultron.api.v2.datalayer.db_record import object_to_record +from vultron.wire.as2.vocab.activities.case import CreateCase as as_CreateCase +from vultron.wire.as2.vocab.objects.case_actor import CaseActor +from vultron.wire.as2.vocab.objects.case_participant import VendorParticipant +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +``` + +Core case-creation BT nodes imported four AS2 vocabulary types and one +adapter-layer utility. The nodes were constructing `CreateCase` AS2 activities, +`VulnerabilityCase` objects, and formatting them with `object_to_record` +directly in core logic. This is the full AS2 serialization stack inside the +core behavior tree. + +**Partially resolved (P65-5):** `object_to_record` import removed; replaced +by `save_to_datalayer` helper. The four AS2 wire type imports +(`as_CreateCase`, `CaseActor`, `VendorParticipant`, `VulnerabilityCase`) +remained and were addressed in P65-6b. + +**Fully resolved (P65-6b):** All four AS2 wire type imports replaced with +domain types from `vultron.core.models.vultron_types`. Core case BT nodes no +longer reference any wire-layer vocabulary types. + +--- + +### V-19 — ✅ `vultron/core/behaviors/case/create_tree.py`, line 44 (RESOLVED P65-6b) + +**Rule:** Rule 1 (core has no wire format imports) +**Severity:** Critical + +`from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase` +— the tree-factory function accepted `VulnerabilityCase` (a wire type) as a +parameter type, meaning calling the factory from a handler required a wire +object to already be present at the call site. + +**Resolved (P65-6b):** Wire type import replaced with domain type +`VultronCase` from `vultron.core.models.vultron_types`. The tree factory now +accepts the domain type, so callers do not need a wire object. + +--- + +### V-20 — ✅ `vultron/behavior_dispatcher.py`, `DispatcherBase.__init__()` (RESOLVED P65-2) + +**Rule:** Rule 2 (core has no framework imports) +**Severity:** Major + +The lazy import `from vultron.api.v2.backend.handler_map import SEMANTICS_HANDLERS` +inside `DispatcherBase.__init__()` with `handler_map=None` was the violation. + +**Resolved (P65-2):** `handler_map` is now a required parameter in +`DispatcherBase.__init__(self, handler_map: dict, ...)`. The `None` default +was removed. The adapter layer (`inbox_handler.py`) injects `SEMANTICS_HANDLERS` +at startup via the lifespan event; no lazy import occurs. + +--- + +### V-21 — ✅ `vultron/behavior_dispatcher.py`, `DispatcherBase.dispatch()` (RESOLVED P65-3) + +**Rule:** Rule 1 (core has no wire format imports) **Severity:** Major -`find_matching_semantics` is the semantic extraction function. The architecture -requires it to be called exactly once per inbound activity, as stage 3 of the -pipeline. Instead, it is called twice: +`dispatch()` used to call `.model_dump_json()` on `raw_activity` from +`dispatchable.payload`. + +**Resolved (P65-3):** `raw_activity` removed from `InboundPayload`. `dispatch()` +now logs using `dispatchable.payload.activity_id` and `dispatchable.payload.object_type` +— plain domain string fields. + +--- + +### V-22 — ✅ `test/test_behavior_dispatcher.py` (RESOLVED P65-7) + +**Rule:** Tests section — core tests must use domain types, not AS2 types +**Severity:** Minor + +```python +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +``` + +The test still imported `as_Create` to test `prepare_for_dispatch()`, which +accepted a raw AS2 activity. Once P65-4 moved `prepare_for_dispatch` to the +adapter layer (`inbox_handler.py`), this test moved with it and the core +dispatcher test no longer needed AS2 types. + +**Resolved (P65-7):** `test_behavior_dispatcher.py` updated to use domain +type fixtures only; wire-layer AS2 imports removed. + +--- + +### V-23 — ✅ `test/core/behaviors/` multiple test files (RESOLVED P65-7) + +**Rule:** Tests section — core tests must not parse AS2 types +**Severity:** Minor + +Files affected: +- `test/core/behaviors/case/test_create_tree.py` +- `test/core/behaviors/report/test_nodes.py` +- `test/core/behaviors/report/test_prioritize_tree.py` +- `test/core/behaviors/report/test_validate_tree.py` +- `test/core/behaviors/test_performance.py` + +All imported AS2 types (`as_Offer`, `as_Accept`, `VulnerabilityReport`, +`as_Service`, `VulnerabilityCase`) to construct test fixtures for core BT +nodes. Core tests should call nodes with domain Pydantic objects, not wire +types. These violations were a downstream consequence of V-15 through V-19: +because the nodes themselves took wire types, the tests had to provide them. + +**Resolved (P65-7):** All five test files updated to use domain type fixtures +from `vultron.core.models.vultron_types`; wire-layer AS2 type imports removed. +Note: `test/core/behaviors/report/test_policy.py` still imports +`VulnerabilityReport` from the wire layer (duck-typing; captured in +TECHDEBT-13a for future cleanup). + +--- + +## 1-Historical. Violations (Historical — All Remediated) + +### V-01 — `vultron/enums.py`, entire file + +**Rule:** Rule 3 (SemanticIntent is a domain type, defined in core) +**Severity:** Major +**Remediated by:** ARCH-1.1 + ARCH-CLEANUP-2 + +`MessageSemantics` now lives in `vultron/core/models/events.py` (moved in +ARCH-1.1). AS2 structural enums (`as_ObjectType`, `as_TransitiveActivityType`, +etc.) moved to `vultron/wire/as2/enums.py` (ARCH-CLEANUP-2). `vultron/enums.py` +re-exports only `MessageSemantics`, `OfferStatusEnum`, and `VultronObjectType` +for backward compatibility. + +--- + +### V-02 — `vultron/types.py`, `DispatchActivity.payload: as_Activity` + +**Rule:** Rule 5 (core functions take and return domain types) +**Severity:** Critical +**Remediated by:** ARCH-1.2 *(regression — see V-02-R)* + +`DispatchActivity.payload` is now typed as `InboundPayload` (a domain type +defined in `vultron/core/models/events.py`). However, `InboundPayload` +includes `raw_activity: Any` which carries the original `as_Activity` +verbatim. See V-02-R. + +--- -1. In `behavior_dispatcher.prepare_for_dispatch` (the intended location). -2. In `handlers/_base.py`'s `verify_semantics` decorator (line 30): - `computed = find_matching_semantics(dispatchable.payload)`. +### V-03 — `vultron/behavior_dispatcher.py`, lines 9 and 17–38 -The second call re-runs AS2 pattern matching against the AS2 payload inside a -function decorated onto domain handler code. This means handler code depends on -the AS2 vocabulary even for internal validation, creating a second -AS2-to-domain coupling point that violates Rule 4's isolation guarantee. +**Rules:** Rule 1 (core has no wire format imports), Rule 5 (core takes domain +types) +**Severity:** Critical +**Remediated by:** ARCH-1.2 *(regression — see V-03-R)* + +`behavior_dispatcher.py` previously imported `as_Activity` directly and +accepted it in `prepare_for_dispatch`. After ARCH-1.2, the dispatcher accepts +`InboundPayload`; however, the wire import `from vultron.wire.as2.extractor +import find_matching_semantics` was added in the same refactor. See V-03-R. + +--- + +### V-04 — `vultron/semantic_map.py` re-invoked from `vultron/api/v2/backend/handlers/_base.py` (line 30) + +**Rule:** Rule 4 (the semantic extractor is the only AS2-to-domain mapping +point) +**Severity:** Major +**Remediated by:** ARCH-1.3 + +`find_matching_semantics` was called twice per activity: once in +`prepare_for_dispatch` and once in the `verify_semantics` decorator. The +decorator now compares `dispatchable.semantic_type` directly without +re-invoking the extractor. --- ### V-05 — `vultron/activity_patterns.py`, `ActivityPattern.match()` method **Rule:** Rule 4 (extractor is the only AS2-to-domain mapping point) -**Severity:** Major +**Severity:** Major +**Remediated by:** ARCH-1.3, ARCH-CLEANUP-1 -`activity_patterns.py` defines `ActivityPattern.match(activity: as_Activity)` -which inspects `activity.as_type` and nested `as_type` fields to determine -whether an activity matches a pattern. This is AS2 structural inspection and -belongs in the extractor (`wire/as2/extractor.py`). It currently lives in a -separate top-level module, and `find_matching_semantics` (the nominal extractor) -delegates to it. The mapping logic is therefore split across two files -(`activity_patterns.py` and `semantic_map.py`) rather than consolidated in one -extractor, as Rule 4 requires. +`ActivityPattern.match()` and `find_matching_semantics` were split across +`activity_patterns.py` and `semantic_map.py`. Both were consolidated into +`vultron/wire/as2/extractor.py`. The old shim files were deleted in +ARCH-CLEANUP-1. --- ### V-06 — `vultron/api/v2/routers/actors.py`, `parse_activity()` function (lines 138–168) **Rule:** Rule 4 (extractor is the only AS2-to-domain mapping point) -**Severity:** Major +**Severity:** Major +**Remediated by:** ARCH-1.3 -`parse_activity` performs AS2 structural parsing inline inside the HTTP driving -adapter. It inspects the raw `dict` for `"type"`, looks up the class in -`VOCABULARY.activities`, and calls `model_validate`. Per the architecture, this -is stage 2 of the inbound pipeline (AS2 Parser) and must live in -`wire/as2/parser.py`. Having it in the router means the HTTP adapter performs -wire-format work directly, and any other driving adapter (CLI, MCP server) that -needs to ingest AS2 would have to duplicate or depend on this router code. +`parse_activity` was moved from the HTTP router to `vultron/wire/as2/parser.py`. +The router now calls `parser.parse_activity(body)` as a thin wrapper. --- ### V-07 — `vultron/api/v2/backend/inbox_handler.py`, `raise_if_not_valid_activity()` and module-level import **Rule:** Rule 1 (core has no wire format imports) -**Severity:** Major +**Severity:** Major +**Remediated by:** ARCH-1.3 -`inbox_handler.py` imports `from vultron.as_vocab import VOCABULARY` at module -level (line 26), then uses it in `raise_if_not_valid_activity` (line 48) to -check `obj.as_type not in VOCABULARY.activities`. This AS2 vocabulary inspection -belongs in the wire layer (stage 2 or 3), not in the backend handler that -should be operating on already-validated, already-semantically-labelled -activities. The backend is the wrong place to detect that something isn't a -valid AS2 activity type. +`inbox_handler.py` previously imported `VOCABULARY` from `vultron.as_vocab` +and used it in `raise_if_not_valid_activity`. After ARCH-1.3, structural AS2 +validation happens in the wire layer (parser/extractor); the backend handler +no longer imports AS2 vocabulary. --- ### V-08 — `vultron/api/v2/backend/inbox_handler.py`, `handle_inbox_item()` collapses three pipeline stages **Rule:** Rule 4 (parse → extract → dispatch are distinct stages) -**Severity:** Critical - -`handle_inbox_item` (lines 68–92) performs all three pipeline stages in a single -function: - -- **Stage 3 check** — `raise_if_not_valid_activity(obj)` re-validates AS2 - structural type. -- **Stage 3 (extraction)** — `prepare_for_dispatch(activity=obj)` maps AS2 to - `DispatchActivity`. -- **Stage 4 (dispatch)** — `dispatch(dispatchable=dispatchable)` invokes the - handler. +**Severity:** Critical +**Remediated by:** ARCH-1.3 -The architecture calls for these to be separate stages: parse → extract → -dispatch. The parse stage is not represented here at all (it was done upstream -in `parse_activity` in the router), and the remaining two stages are collapsed -together with an extra AS2 validation check mixed in. There is no clean seam -between the wire layer finishing its work and the domain layer beginning its -work. +`handle_inbox_item` collapsed parse, extract, and dispatch. After ARCH-1.3, +the router performs parse (via `wire/as2/parser.py`) and extract (via +`wire/as2/extractor.py`) before the dispatcher stage; the stages are now +clearly separated. --- @@ -152,235 +492,246 @@ work. **Rule:** Rule 2 (core has no framework imports), Rule 6 (driven adapters injected via ports) -**Severity:** Major +**Severity:** Major +**Remediated by:** ARCH-1.4, ARCH-CLEANUP-1 -`semantic_handler_map.py` is a top-level `vultron/` module that maps -`MessageSemantics` (a domain enum) to handler functions. Handler functions live -in `vultron.api.v2.backend.handlers`, which is an application-layer module. The -mapping file therefore creates a dependency from a domain-level concern onto the -application adapter layer. The lazy import (line 25: `from vultron.api.v2.backend -import handlers as h`) masks a circular import that is itself a symptom of this -layering problem. Per AGENTS.md, lazy imports are a code smell that SHOULD be -refactored. +`semantic_handler_map.py` (top-level) was deleted in ARCH-CLEANUP-1. The +handler map now lives in `vultron/api/v2/backend/handler_map.py` (adapter +layer), removing the domain→adapter dependency. --- ### V-10 — All handler files in `vultron/api/v2/backend/handlers/`, direct datalayer instantiation -**Rule:** Rule 6 (driven adapters injected via ports) +**Rule:** Rule 6 (driven adapters injected via ports) **Severity:** Major +**Remediated by:** ARCH-1.4 *(partial regression — see V-10-R)* -Every handler function calls `dl = get_datalayer()` directly inside the function -body via a lazy import. Examples: `report.py` lines 49, 108, 195, 301, 358; -`case.py` line 43; and similar patterns throughout all handler files. The -architecture requires core services to receive port implementations via -dependency injection, never instantiate them directly. Handler functions are the -closest thing to "core services" in the current layout, and they all bypass the -port abstraction by calling the concrete TinyDB factory directly. +All handler functions now receive `dl: DataLayer` via parameter injection. +`get_datalayer()` is no longer called inside handler bodies. However, +`inbox_handler.py` creates the DataLayer at module load time and passes it +to the dispatcher at construction. See V-10-R. --- ### V-11 — Handler files use `isinstance` checks against AS2 types -**Rule:** Rule 5 (core functions take and return domain types) +**Rule:** Rule 5 (core functions take and return domain types) **Severity:** Major +**Remediated by:** ARCH-CLEANUP-3 *(regression — see V-11-R)* -Handler functions check `isinstance(created_obj, VulnerabilityReport)` (e.g., -`report.py` lines 33, 93) and `isinstance(accepted_report, VulnerabilityReport)` -(line 170) where `VulnerabilityReport` is imported from -`vultron.as_vocab.objects.vulnerability_report`. These checks are inside handler -functions — nominally domain logic — but they operate on AS2 structural types -rather than domain types. If the wire format changed, these checks would break. -The correct behaviour would be for the payload type to already guarantee what -kind of object is present, via a domain-typed `InboundPayload` produced by the -extractor. +The specific `isinstance(obj, VulnerabilityReport)` calls were removed. The +underlying pattern — handlers navigating AS2 object graphs via `raw_activity` +— remains. See V-11-R. --- ### V-12 — `test/test_behavior_dispatcher.py`, core test uses AS2 types **Rule:** Tests section — "core tests should call service functions directly -with domain Pydantic objects" +with domain Pydantic objects" **Severity:** Minor +**Remediated by:** ARCH-CLEANUP-3 *(partial regression — see V-22)* -`test_behavior_dispatcher.py` imports `as_Create`, `VulnerabilityReport`, and -`as_TransitiveActivityType` from `vultron.as_vocab` to construct test inputs for -`prepare_for_dispatch` and `DirectActivityDispatcher.dispatch`. The dispatcher is -a core component; its tests should not require AS2 construction. The test is -testing core behaviour through the wire format, which means it would break if -the wire format changed even though the dispatch logic had not changed. +The test was updated to use domain types from `vultron.core.models.events`. +However, the same test still imports `as_Create` from the wire layer to +construct the input to `prepare_for_dispatch` (see V-22). The `raw_activity` +field on `InboundPayload` is accessed and its AS2 attribute verified in the +test assertion. --- ## 2. Remediation Plan -### R-01: Separate `MessageSemantics` from AS2 enums (addresses V-01) - -**What moves where:** -Create `vultron/core/models/events.py` (or equivalent) containing only -`MessageSemantics`. Move AS2 structural enums (`as_ObjectType`, -`as_TransitiveActivityType`, etc.) to `vultron/wire/as2/enums.py`. - -**New abstraction needed:** None. Pure file reorganisation. +### R-07: Remove `raw_activity` from `InboundPayload`; complete AS2 extraction in the wire layer -**Dependency:** Must happen before R-02, because R-02 defines a domain -`InboundPayload` that imports `MessageSemantics`. +(addresses V-02-R, V-11-R, V-21) ---- +**What moves where:** +The `raw_activity: Any` field must be removed from `InboundPayload`. All +information that handlers currently extract from `raw_activity` (nested object +IDs, actor reference, object graphs) must be surfaced as typed domain fields in +`InboundPayload` or produced by the extractor before dispatch. -### R-02: Introduce `InboundPayload` domain type; remove AS2 from `DispatchActivity` (addresses V-02, V-03, V-11) +The extractor at `wire/as2/extractor.py` must be extended so that +`find_matching_semantics` (or a new `extract_intent` function) produces a +fully-populated `InboundPayload` with all fields that handlers need. Handlers +must never call `.as_object`, `.as_id`, or `.as_type` on anything — +all navigation of the AS2 object graph must happen inside the extractor. -**What moves where:** -Define `InboundPayload` in `vultron/core/models/events.py` as a domain Pydantic -model. The extractor produces it from an `as_Activity`. `DispatchActivity.payload` -becomes `InboundPayload`, not `as_Activity`. +Rough sketch of the enriched payload: ```python -# core/models/events.py (notional sketch) +# core/models/events.py class InboundPayload(BaseModel): - semantic_type: MessageSemantics activity_id: str actor_id: str - object_type: str # domain vocabulary, not AS2 type string - object_id: str | None - raw_object: dict # opaque; handlers that need detail use this + object_type: str | None = None # domain vocabulary string, not AS2 type + object_id: str | None = None + target_type: str | None = None + target_id: str | None = None + # For nested-activity patterns (Accept(Offer(...)), etc.) + inner_object_type: str | None = None + inner_object_id: str | None = None + # No raw_activity field ``` -**New abstraction needed:** `InboundPayload` in `core/`. +**New abstraction needed:** An `extract_intent` function in +`wire/as2/extractor.py` that returns `(MessageSemantics, InboundPayload)` with +all domain-relevant fields populated. -**Dependency:** Requires R-01 first. +**Dependency:** Requires understanding of every field each handler reads from +`raw_activity` before starting. Do not remove `raw_activity` until every +handler has been audited and the extractor updated. --- -### R-03: Consolidate parsing into `wire/as2/parser.py`; remove `parse_activity` from router (addresses V-06) +### R-08: ✅ Move `DataLayer` port definition into `core/ports/` -**What moves where:** -Move `parse_activity` from `vultron/api/v2/routers/actors.py` to a new -`vultron/wire/as2/parser.py`. The router calls `parser.parse_activity(body)`. +(addresses V-13, V-14 — COMPLETE P65-1) -```python -# adapters/driving/http_inbox.py (notional) -from vultron.wire.as2.parser import parse_activity -from vultron.wire.as2.extractor import extract_intent - -@router.post("/{actor_id}/inbox/") -async def post_actor_inbox(...): - as2_activity = parse_activity(body) # stage 2 - intent, payload = extract_intent(as2_activity) # stage 3 - background_tasks.add_task(dispatch, intent, payload) # stage 4 - return Response(status_code=202) -``` +**What was done:** +`DataLayer` Protocol moved from `vultron/api/v2/datalayer/abc.py` to +`vultron/core/ports/activity_store.py`. The TinyDB implementation in +`vultron/api/v2/datalayer/tinydb_backend.py` stays in the adapter layer and +imports from `core/ports/`. The old location (`api/v2/datalayer/abc.py`) is +now a backward-compat re-export shim. + +`Record` imports removed from all core BT nodes (P65-5). Core BT nodes now +use a `StorableRecord` domain type and the `save_to_datalayer` helper in +`core/behaviors/helpers.py`, which constructs `StorableRecord` without +referencing the adapter-layer `Record`. + +--- + +### R-09: ✅ Remove wire-layer imports from `core/behaviors/` + +(addresses V-15, V-16, V-17, V-18, V-19 — COMPLETE P65-5, P65-6b) -**New abstraction needed:** `wire/as2/parser.py` module. +**Status:** Fully resolved. Adapter-layer persistence imports (`object_to_record`, +`OfferStatus`, `Record`) removed from all core BT nodes (P65-5). All remaining +AS2 wire type imports (`CreateCase`, `VulnerabilityCase`, `CaseActor`, +`VendorParticipant`, `VulnerabilityReport`, `ParticipantStatus`) replaced +with domain types from `vultron.core.models.vultron_types` (P65-6b). -**Dependency:** Requires R-04 (extractor consolidation) to be started -concurrently, because the router change calls both. +Core BT nodes in `report/nodes.py`, `case/nodes.py`, `report/policy.py`, and +`case/create_tree.py` now import only domain types. The domain type +abstractions added in P65-6b (e.g., `VultronReport`, `VultronCase`, +`VultronParticipant`) serve as the boundary between wire serialization and core +behavior logic. --- -### R-04: Consolidate semantic extraction into `wire/as2/extractor.py`; remove second call site (addresses V-04, V-05, V-07, V-08) +### R-10: ✅ Decouple `behavior_dispatcher.py` from the wire layer and adapter handler map -**What moves where:** -Move `find_matching_semantics` and the `ActivityPattern.match()` logic into -`vultron/wire/as2/extractor.py`. `extractor.extract_intent(as2_activity)` returns -`(MessageSemantics, InboundPayload)`. Remove the second invocation of -`find_matching_semantics` from `handlers/_base.py`'s `verify_semantics` -decorator. Remove `raise_if_not_valid_activity` from `inbox_handler.py` -(structural AS2 validation belongs in the parser, not the backend). +(addresses V-03-R, V-20, V-21 — COMPLETE P65-2, P65-3, P65-4) -`vultron/semantic_map.py` and `vultron/activity_patterns.py` at the root of -`vultron/` become dead code and are deleted after their logic moves. +**Status:** Fully resolved. -**New abstraction needed:** `wire/as2/extractor.py` that is the sole owner of the -pattern list and matching logic. +- `prepare_for_dispatch()` relocated to the adapter layer (`inbox_handler.py`) + in P65-4. `behavior_dispatcher.py` no longer calls `extract_intent()` or + imports from `wire/as2/extractor`. +- The `handler_map=None` lazy-import fallback was eliminated in P65-2; + `handler_map` is now a required constructor argument injected at startup. +- `raw_activity` field removed from `InboundPayload` in P65-3; `dispatch()` + logs using typed domain fields only. -**Dependency:** Requires R-02 (`InboundPayload`) to exist, and R-01 (enums -split) to avoid pulling AS2 enums into the extractor's return type. +--- + +### R-11: ✅ Fix module-level datalayer instantiation in `inbox_handler.py` + +(addresses V-10-R — COMPLETE P65-2) + +**Status:** Fully resolved. The module-level +`DISPATCHER = get_dispatcher(..., dl=get_datalayer())` and per-call +`DISPATCHER.dl = get_datalayer()` mutation were eliminated in P65-2. The +DataLayer is now wired into the dispatcher via a FastAPI lifespan event at +application startup, with no import-time or per-request instantiation. --- -### R-05: Inject the data layer into handlers via a port; remove `get_datalayer()` calls (addresses V-10) +## 2-Historical. Remediation Plan (Completed) -**What moves where:** -Add a `DataLayer` parameter (or context object) to each handler's call -signature, or inject it via a dependency container. The handler registry -(`semantic_handler_map.py`) or the dispatcher binds the concrete implementation -at startup, not inside the handler body. +The following remediation items were completed through ARCH-1.1–ARCH-1.4 and +ARCH-CLEANUP-1 through ARCH-CLEANUP-3. They are preserved for reference. -```python -# notional handler signature after injection -def create_report(dispatchable: DispatchActivity, dl: DataLayer) -> None: - ... -``` +### R-01: Separate `MessageSemantics` from AS2 enums (addresses V-01) ✅ + +**Completed by:** ARCH-1.1 + ARCH-CLEANUP-2. `MessageSemantics` now lives in +`vultron/core/models/events.py`. AS2 structural enums moved to +`vultron/wire/as2/enums.py`. `vultron/enums.py` re-exports for compatibility. -The lazy local imports of `get_datalayer` in every handler file are replaced by -a parameter or a bound partial provided by the dispatcher. +--- -**New abstraction needed:** No new interface (DataLayer Protocol already exists -in `datalayer/abc.py`). Requires wiring change in the dispatcher and/or a -context object passed through. +### R-03: Consolidate parsing into `wire/as2/parser.py` (addresses V-06) ✅ -**Dependency:** Can proceed in parallel with other remediations. +**Completed by:** ARCH-1.3. `parse_activity` is now in +`vultron/wire/as2/parser.py`; the router wraps it. --- -### R-06: Move `semantic_handler_map.py` into the adapter layer (addresses V-09) +### R-04: Consolidate semantic extraction into `wire/as2/extractor.py` (addresses V-04, V-05, V-07, V-08) ✅ -**What moves where:** -`semantic_handler_map.py` should move to `vultron/adapters/driving/` or into the -dispatcher configuration. The handler map is an adapter-layer concern — it binds -domain `MessageSemantics` values to concrete handler implementations. Placing it -in the root `vultron/` package allows it to be imported by core-level modules, -creating implicit dependencies on the adapter layer. +**Completed by:** ARCH-1.3 + ARCH-CLEANUP-1. `ActivityPattern`, +`SEMANTICS_ACTIVITY_PATTERNS`, and `find_matching_semantics` are consolidated +in `wire/as2/extractor.py`. The `verify_semantics` decorator compares +`dispatchable.semantic_type` directly. + +--- -**New abstraction needed:** None. +### R-06: Move handler map into the adapter layer (addresses V-09) ✅ -**Dependency:** Should happen after R-04 (extractor consolidation) so the -handler map no longer needs to be imported by extractor-adjacent code. +**Completed by:** ARCH-1.4 + ARCH-CLEANUP-1. Handler map moved to +`vultron/api/v2/backend/handler_map.py`. --- ## 3. What Is Already Clean -**`DataLayer` Protocol (`vultron/api/v2/datalayer/abc.py`)** — Correctly defines -the persistence port as a Protocol class, allowing duck-typed injection and -decoupling the domain from the concrete TinyDB implementation. - -**`ActivityDispatcher` Protocol and `DispatcherBase` in `behavior_dispatcher.py`** — -The Protocol-based dispatcher design is architecturally sound. The `dispatch` -method signature accepts `DispatchActivity` rather than a raw dict or HTTP -request, which is the correct abstraction. The problem is in what `DispatchActivity` -carries (see V-02), not in the dispatcher pattern itself. - -**`DispatchActivity` wrapper concept in `vultron/types.py`** — The idea of -wrapping an activity with its pre-computed semantic type before dispatching is -exactly right. It models the hand-off between the extractor stage and the -dispatch stage. Only the `payload` type needs to change (see R-02). - -**FastAPI 202 + `BackgroundTasks` in `actors.py`** — The inbox endpoint correctly -returns 202 and schedules work via `background_tasks.add_task`, satisfying the -spec requirement that the HTTP layer return quickly without blocking on -processing. - -**`@verify_semantics` decorator pattern** — The intent of wrapping handlers with a -semantic-type guard is good practice: it catches misrouted activities early. -The implementation flaw is that the guard re-invokes the AS2 pattern matcher -(V-04) rather than comparing the pre-computed semantic type already present in -`dispatchable.semantic_type`. The fix is one line: compare -`dispatchable.semantic_type != expected_semantic_type` directly and remove the -`find_matching_semantics` call. - -**`MessageSemantics` enum values** — The vocabulary itself is well-designed. The -values express domain-level intent (`CREATE_REPORT`, `ENGAGE_CASE`, -`INVITE_TO_EMBARGO_ON_CASE`) without leaking AS2 verb names or object type names -into the enum values. This is the correct abstraction; it just needs to live in -the right file (see R-01). - -**Handler function naming and `@verify_semantics` usage** — Every handler -consistently uses the decorator and accepts `DispatchActivity`. This uniformity -makes R-02 (changing the payload type) a tractable mechanical refactor rather -than a deep logic change. - -**`vultron/errors.py` and `VultronError` base** — The domain exception hierarchy -is cleanly defined in a neutral module with no framework or wire-format -dependencies. +**`wire/as2/extractor.py`** — The extractor is correctly consolidated as the +sole location for AS2-to-domain vocabulary mapping (Rule 4). `ActivityPattern` +and `find_matching_semantics` live here and nowhere else. This is the correct +seam. + +**`wire/as2/parser.py`** — Clean wire-layer module. Raises domain parse errors; +contains no domain logic; no handler logic or case management. + +**`vultron/api/v2/routers/actors.py` HTTP inbox endpoint** — The router +correctly delegates parsing to `wire/as2/parser.py` via the local +`parse_activity` wrapper, returns 202 immediately, and schedules inbox +processing via `BackgroundTasks`. The three-stage sequence (parse → store → +schedule) is correctly sequenced. + +**`vultron/api/v2/backend/handlers/_base.py`, `verify_semantics` decorator** — +Correctly compares `dispatchable.semantic_type` directly against +`expected_semantic_type` without re-invoking pattern matching (V-04 remediated +and not regressed). The decorator design is sound. + +**`vultron/api/v2/backend/handler_map.py`** — Handler map lives in the adapter +layer (Rule 2 boundary respected from the adapter side). Binding +`MessageSemantics` values to concrete handler functions is correctly isolated +here. + +**`vultron/core/models/events.py`, `MessageSemantics` enum** — The domain +vocabulary is cleanly defined here with no wire-format dependencies. Enum +values (`CREATE_REPORT`, `ENGAGE_CASE`, etc.) express domain intent, not AS2 +verbs. + +**`ActivityDispatcher` Protocol in `behavior_dispatcher.py`** — The +Protocol-based dispatcher design is correct. The `dispatch` method accepts +`DispatchActivity`, not a raw dict or HTTP request. The problem is entirely in +`prepare_for_dispatch` (which calls the wire extractor) and the `raw_activity` +escape hatch, not in the dispatcher abstraction itself. + +**`vultron/errors.py` and `VultronError` base** — Domain exception hierarchy +with no framework or wire-format dependencies. + +**`vultron/adapters/` stub package** — The `adapters/driving/`, `adapters/driven/`, +and `adapters/connectors/` stubs are correctly structured and contain only stub +or protocol definitions. `adapters/driving/http_inbox.py` documents the intended +pipeline without implementing it. `ConnectorPlugin` Protocol in +`adapters/connectors/base.py` is the right abstraction. + +**`vultron/adapters/connectors/loader.py`** — Correctly specifies entry-point +discovery via `importlib.metadata`. The pattern (not yet implemented) is +architecturally correct per Rule 7. diff --git a/notes/bt-integration.md b/notes/bt-integration.md index 8ae27c81..5ef9fec8 100644 --- a/notes/bt-integration.md +++ b/notes/bt-integration.md @@ -4,12 +4,13 @@ Handler functions in `vultron/api/v2/backend/handlers` MAY orchestrate complex workflows using the `py_trees` behavior tree library via the bridge -layer in `vultron/behaviors/`. Simple CRUD-style handlers use procedural code -directly. +layer in `vultron/core/behaviors/`. Simple CRUD-style handlers use procedural +code directly. **Key boundary**: `vultron/bt/` is the simulation BT engine (custom, do NOT -modify or reuse for prototype handlers). `vultron/behaviors/` uses `py_trees` -for prototype handler BTs. These coexist independently and MUST NOT be merged. +modify or reuse for prototype handlers). `vultron/core/behaviors/` uses +`py_trees` for prototype handler BTs. These coexist independently and MUST +NOT be merged. --- @@ -270,12 +271,12 @@ tests). ## EvaluateCasePriority: Outgoing Direction Only -`EvaluateCasePriority` (in `vultron/behaviors/report/nodes.py`) is a **stub -node for the outgoing direction** — when the local actor decides whether to -engage or defer a case after receiving a validated report. +`EvaluateCasePriority` (in `vultron/core/behaviors/report/nodes.py`) is a +**stub node for the outgoing direction** — when the local actor decides whether +to engage or defer a case after receiving a validated report. The receive-side trees (`EngageCaseBT`, `DeferCaseBT` in -`vultron/behaviors/report/prioritize_tree.py`) do **not** use +`vultron/core/behaviors/report/prioritize_tree.py`) do **not** use `EvaluateCasePriority`. They only record the **sender's already-made decision** by updating the sender's `CaseParticipant.participant_status[].rm_state`. diff --git a/notes/case-state-model.md b/notes/case-state-model.md index edf05a84..5c1f63d7 100644 --- a/notes/case-state-model.md +++ b/notes/case-state-model.md @@ -89,7 +89,7 @@ The how-to doc describes a UML class diagram for a `Case` object with: This design was written before the ActivityStreams vocabulary was adopted. -### Current Implementation (`vultron/as_vocab/objects/vulnerability_case.py`) +### Current Implementation (`vultron/wire/as2/vocab/objects/vulnerability_case.py`) The `VulnerabilityCase` class is a Pydantic model inheriting from `VultronObject` (which inherits from the ActivityStreams `as_Object`). It @@ -167,7 +167,7 @@ Participant-Specific: RM ↔ CS_vfd ### Implementation: CaseStatus vs. ParticipantStatus The canonical Python implementation is in -`vultron/as_vocab/objects/case_status.py`: +`vultron/wire/as2/vocab/objects/case_status.py`: **`CaseStatus`** — participant-agnostic, one per case: @@ -285,8 +285,9 @@ CaseActor records the event to an **append-only event log on the case** object's timestamp would break the append-only history invariant and allow event-ordering disagreements across actor copies. -**Cross-reference**: `vultron/as_vocab/objects/vulnerability_case.py` (the -`current_status` property), `vultron/as_vocab/objects/case_status.py`. +**Cross-reference**: `vultron/wire/as2/vocab/objects/vulnerability_case.py` +(the `current_status` property), +`vultron/wire/as2/vocab/objects/case_status.py`. --- @@ -304,7 +305,8 @@ grep -rn "\.participant_status" vultron/ test/ ``` As of the last review, `handlers.py` alone has approximately 20 call sites. -Total scope across `behaviors/` and tests makes this a high-breakage change. +Total scope across `core/behaviors/` and tests makes this a high-breakage +change. **Recommended approach**: Do both renames (`case_statuses` and `participant_statuses`) in a single PR to keep the diff localized and avoid @@ -318,9 +320,9 @@ Pending)"; `specs/case-management.md` CM-03-006. ## CaseEvent Model for Trusted Timestamps (SC-PRE-1) ✅ Implemented The `CaseEvent` model is implemented in -`vultron/as_vocab/objects/case_event.py`. `VulnerabilityCase` has an +`vultron/wire/as2/vocab/objects/case_event.py`. `VulnerabilityCase` has an `events: list[CaseEvent]` field and a `record_event(object_id, event_type)` -append-only helper. Tests in `test/as_vocab/test_case_event.py` (19 tests) +append-only helper. Tests in `test/wire/as2/vocab/test_case_event.py` cover creation, serialization, and round-trip through TinyDB. The design is described below for reference. The key invariant is diff --git a/notes/codebase-structure.md b/notes/codebase-structure.md index 0805f16e..4137f649 100644 --- a/notes/codebase-structure.md +++ b/notes/codebase-structure.md @@ -1,40 +1,67 @@ # Codebase Structure Notes -## Top-Level Module Reorganization (Future Refactoring) +## Top-Level Module Reorganization Status Several top-level modules in `vultron/` were created at the top level for -development convenience but are candidates for reorganization into submodules -as the codebase matures. This is not a high priority for the prototype, but -worth tracking. - -**Candidates for reorganization:** - -- `vultron/activity_patterns.py` — pattern definitions; could move to - `vultron/dispatch/` or `vultron/messaging/` -- `vultron/behavior_dispatcher.py` — dispatch logic; same candidate submodule -- `vultron/dispatcher_errors.py` — currently kept at top level to avoid - circular imports; see `specs/code-style.md` CS-05-001 -- `vultron/enums.py` — primary enum registry; see Enum Refactoring section - below -- `vultron/errors.py` — top-level error base; submodule errors already exist - at `vultron/api/v2/errors.py` -- `vultron/semantic_handler_map.py` — handler registry -- `vultron/semantic_map.py` — semantics-to-pattern registry -- `vultron/types.py` — shared type aliases; a neutral module used to break - circular import chains +development convenience but have since been reorganized as part of the +hexagonal architecture refactoring. + +**Completed reorganizations (ARCH-CLEANUP-1 through P60-2):** + +- `vultron/activity_patterns.py` — merged into `vultron/wire/as2/extractor.py` +- `vultron/semantic_map.py` — merged into `vultron/wire/as2/extractor.py` +- `vultron/semantic_handler_map.py` — moved to + `vultron/api/v2/backend/handler_map.py` +- `vultron/as_vocab/` — moved to `vultron/wire/as2/vocab/` (P60-1) +- `vultron/behaviors/` — moved to `vultron/core/behaviors/` (P60-2) +- AS2 structural enums — moved from `vultron/enums.py` to + `vultron/wire/as2/enums.py` (ARCH-CLEANUP-2) +- `MessageSemantics` — moved to `vultron/core/models/events.py` (ARCH-1.1) + +**Still at top level (pending future relocation):** + +- `vultron/behavior_dispatcher.py` — core dispatch logic; no wire imports. + Belongs in `vultron/core/` once circular import constraints are resolved. +- `vultron/dispatcher_errors.py` — kept at top level to avoid circular imports; + see `specs/code-style.md` CS-05-001. Belongs in `vultron/core/` alongside + the dispatcher. +- `vultron/enums.py` — backward-compat re-export shim only; should be deleted + once all callers import from `vultron/core/models/events.py` (for + `MessageSemantics`) and `vultron/wire/as2/enums.py` (for AS2 structural + enums) directly. +- `vultron/errors.py` — top-level error base; submodule errors exist at + `vultron/api/v2/errors.py` +- `vultron/types.py` — shared type aliases; neutral module used to break + circular import chains. Contents should be migrated into + `vultron/core/types.py` or `vultron/wire/types.py` as appropriate once + circular imports are resolved. **Constraint**: `dispatcher_errors.py` and `types.py` MUST remain accessible to both core dispatch modules and `api/v2/` without creating circular imports. Any reorganization MUST preserve this constraint. See `AGENTS.md` "Circular Imports" section for the import chain rules. +**Future cleanup tasks (post-P60)**: + +1. Move `vultron/behavior_dispatcher.py` to `vultron/core/` +2. Move `vultron/dispatcher_errors.py` to `vultron/core/` alongside the + dispatcher +3. Delete `vultron/enums.py` once all callers have been updated to import + from the canonical locations (`vultron/core/models/events.py` and + `vultron/wire/as2/enums.py`) +4. Audit `vultron/types.py` and migrate contents to `vultron/core/types.py` + or `vultron/wire/types.py` as appropriate + --- ## Enum Refactoring -Enums are currently scattered across multiple locations in the codebase: +Enums are currently organized across multiple locations in the codebase: -- `vultron/enums.py` — primary application-layer enums (e.g., `MessageSemantics`) +- `vultron/core/models/events.py` — `MessageSemantics` (domain enum) +- `vultron/wire/as2/enums.py` — AS2 structural enums (`as_ObjectType`, + `as_TransitiveActivityType`, etc.) +- `vultron/enums.py` — backward-compat re-exports only - `vultron/case_states/enums/` — case state enums, split into submodules: - `cvss_31.py` - `embargo.py` @@ -45,18 +72,102 @@ Enums are currently scattered across multiple locations in the codebase: - `utils.py` - `vep.py` - `zerodays.py` -- `vultron/as_vocab/` — vocabulary-level type enums +- `vultron/wire/as2/vocab/` — vocabulary-level type enums (moved from + `vultron/as_vocab/`) + +**Target organization**: Each level of the package hierarchy SHOULD have a +dedicated `enums.py` module (or `enums/` subpackage if there are many enums) +so that enums are easy to find and manage. For example: + +- `vultron/core/models/events/enums.py` — `MessageSemantics` (moving from + `events.py` base) +- `vultron/core/models/enums/` — enums shared across core models (e.g., + `CVDRole`, state machine enums migrated from `vultron/bt` and + `vultron/case_states`) +- `vultron/wire/as2/enums.py` — AS2 structural enums (already in place) + +Enums imported from outside `core` that are used in `core` are candidates +for relocation into `core` (refactoring from their original location as +needed). In particular: + +- Enums in `vultron/bt/` and `vultron/case_states/` that represent domain + concepts (not BT-engine internals) SHOULD migrate to `core/models/enums/`. +- If a given area has many enums, split them into an `enums/` subpackage + with multiple files rather than one large `enums.py`. + +**Not a high priority for the prototype**, but each new enum SHOULD be +placed at the correct layer from the start to avoid accumulating more +technical debt. + +--- + +## Core Object Modules: Split `vultron_types.py` + +`vultron/core/models/vultron_types.py` currently bundles multiple core object +types into a single file. These SHOULD be split into individual modules for +better organization, following the same pattern used in `vultron/wire/as2/vocab/objects/`: + +- Each core domain object class gets its own module + (e.g., `vultron/core/models/report.py`, `vultron/core/models/case.py`) +- `vultron/core/models/__init__.py` or a thin re-export module can re-export + all types for callers that import from `vultron.core.models` + +This makes individual classes easier to find, reduces merge conflicts, and +matches the source layout pattern already established in the wire layer. + +--- + +## `CVDRoles` Design Decision: StrEnum List, Not Flag + +The `CVDRoles` enum in `vultron/bt/roles/states.py` uses bitwise `Flag` +semantics. This design is acceptable within `vultron/bt/` (the legacy BT +simulator) but MUST NOT be used elsewhere. + +**For all new code in `core` and `wire`**, represent CVD roles as a +`list[CVDRole]` where `CVDRole` is a `StrEnum`: + +```python +# vultron/core/models/enums/cvd_role.py (proposed location) +from enum import StrEnum + +class CVDRole(StrEnum): + FINDER = "finder" + REPORTER = "reporter" + VENDOR = "vendor" + COORDINATOR = "coordinator" + OTHER = "other" +``` + +When roles appear on case objects or participant objects, the field type +SHOULD be `list[CVDRole]`. This is easier to work with than bitwise flags: +membership checks use `if CVDRole.VENDOR in participant.roles` instead of +bitwise tests. + +The old `CVDRoles` `Flag` class can be renamed `CVDRoleFlags` and left in +`vultron/bt/roles/states.py` as long as the legacy BT simulator still uses it. +When the BT simulator is eventually retired or migrated, `CVDRoleFlags` can be +removed. + +--- -**Proposed future reorganization**: Consider a `vultron/enums/` package with -submodules grouped by domain: +## State Machine Library Consideration -- `vultron/enums/message_semantics.py` -- `vultron/enums/case_states.py` -- `vultron/enums/vocabulary.py` -- etc. +The RM, EM, and CS state machines are currently implemented as manually-defined +enums with no formal state machine enforcement. The +[`transitions`](https://github.com/pytransitions/transitions) Python library +provides a clean, declarative way to define state machines with guards, +callbacks, and transition tables. -This would improve discoverability and allow a unified review of redundant or -unused enums. Not a high priority for the prototype. +**Long-term consideration**: Integrating `transitions` would make it easier to +define and maintain the RM/EM/CS state machines, enforce valid state transitions +at runtime, and generate transition diagrams for documentation. This is not a +high priority for the prototype, but may become valuable as the state machines +grow more complex or when implementing actor independence (PRIORITY 100). + +**Open Question**: Should `transitions` (or an equivalent) be adopted before or +after the domain model separation (see `notes/domain-model-separation.md`)? The +state machines are a core domain concept; their implementation should live in +`vultron/core/` regardless of which library is used. --- @@ -103,6 +214,16 @@ numbers, but they are actually more like **distinct layers**: | Backend services layer | `vultron/api/v2/backend/` | Business logic, handlers, triggerable behaviors, DataLayer | | Examples layer | `vultron/api/v1/` | Canned example responses; not an active coordination layer | +**Key distinction**: `vultron/api/v2/` is driven by AS2 messages arriving in +inboxes (semantic, protocol-level); `vultron/api/v1/` is essentially a **direct +DataLayer access** backend for prototype visibility and management purposes +(administrative, near-direct port access). `api/v1` is still an adapter layer +in the hexagonal sense — it just happens to interface almost directly with the +DataLayer port rather than routing through full semantic handling. It SHOULD be +refactored to fit the port-and-adapter design when `api/v2/` has been fully +cleaned up. There may be a very thin core use-case layer it interfaces with, +or it may talk directly to the DataLayer port. + **Proposed future reorganization**: Rename to reflect layer semantics rather than version numbers, e.g.: @@ -120,17 +241,17 @@ for a rename, but the decision should be recorded in `AGENTS.md` or here. --- -## Module Boundary: `vultron/bt/` vs `vultron/behaviors/` +## Module Boundary: `vultron/bt/` vs `vultron/core/behaviors/` These two trees coexist and MUST NOT be merged or confused: | Module | Purpose | BT Engine | Status | |--------|---------|-----------|--------| | `vultron/bt/` | Original simulation (custom engine) | Custom (`vultron.bt.base`) | Legacy; do not modify for prototype handlers | -| `vultron/behaviors/` | Prototype handler BTs | `py_trees` (v2.2.0+) | Active development | +| `vultron/core/behaviors/` | Prototype handler BTs | `py_trees` (v2.2.0+) | Active development | The `vultron/sim/` module also exists as another simulation-related module and -MUST NOT be confused with `vultron/bt/` or `vultron/behaviors/`. +MUST NOT be confused with `vultron/bt/` or `vultron/core/behaviors/`. See `notes/bt-integration.md` for architectural decisions about the BT layer. @@ -152,15 +273,16 @@ from `vultron.api.v2.backend.handlers` remain stable. ## Vocabulary Examples Module Structure (Completed) -`vultron/as_vocab/examples/` contains vocabulary example submodules organized -by topic: `_base.py`, `actor.py`, `case.py`, `embargo.py`, `note.py`, -`participant.py`, `report.py`, `status.py`. The top-level -`vocab_examples.py` in that package re-exports all public names. +`vultron/wire/as2/vocab/examples/` contains vocabulary example submodules +organized by topic: `_base.py`, `actor.py`, `case.py`, `embargo.py`, `note.py`, +`participant.py`, `report.py`, `status.py`. The top-level `vocab_examples.py` +in that package re-exports all public names. -The compatibility shim at `vultron/scripts/vocab_examples.py` has been removed -(TECHDEBT-6, commit 29005e4). Import directly from `vultron.as_vocab.examples`. +The old `vultron/as_vocab/` package was relocated to `vultron/wire/as2/vocab/` +as part of P60-1. Import directly from `vultron.wire.as2.vocab.examples`. -**See**: `plan/IMPLEMENTATION_PLAN.md` Phase TECHDEBT-5 and TECHDEBT-6 (both completed). +**See**: `plan/IMPLEMENTATION_PLAN.md` Phase TECHDEBT-5, TECHDEBT-6, and P60-1 +(all completed). --- @@ -240,6 +362,40 @@ base64url encoding). --- +## Technical Debt: Test Directory Layout Mismatch (TECHDEBT-11) + +After P60-1 and P60-2 (package relocations), the test directories +`test/as_vocab/` and `test/behaviors/` remain at their old locations. +All tests already import from the new canonical paths +(`vultron.wire.as2.vocab.*` and `vultron.core.behaviors.*`), so tests pass. +The directory structure does not mirror the source layout yet. + +**Target moves**: + +- `test/as_vocab/` → `test/wire/as2/vocab/` +- `test/behaviors/` → `test/core/behaviors/` + +Both moves are mechanical: create the new directories, move files, update +`conftest.py` and `__init__.py`, delete old directories. No import changes +are needed (they are already correct). + +--- + +## Technical Debt: Deprecated HTTP Status Constant (TECHDEBT-12) + +`starlette.status.HTTP_422_UNPROCESSABLE_ENTITY` is deprecated in favour +of `HTTP_422_UNPROCESSABLE_CONTENT`. Usages remain in trigger service files: + +- `vultron/api/v2/backend/trigger_services/embargo.py` +- `vultron/api/v2/backend/trigger_services/report.py` +- `vultron/api/v2/backend/trigger_services/_helpers.py` + +This generates a `DeprecationWarning` in test output. Fix is a simple +string replacement: `HTTP_422_UNPROCESSABLE_ENTITY` → +`HTTP_422_UNPROCESSABLE_CONTENT`. + +--- + ## Known Gap: Outbox Delivery Not Implemented `vultron/api/v2/data/actor_io.py` has a placeholder that appends strings to diff --git a/notes/demo-future-ideas.md b/notes/demo-future-ideas.md index 22b7c73b..37878760 100644 --- a/notes/demo-future-ideas.md +++ b/notes/demo-future-ideas.md @@ -53,4 +53,4 @@ containers, communicating through the Vultron Protocol, with the CaseActor managing the case state and enforcing the rules around who can do what within the case. CaseActor is probably also a "spin up on demand" container that gets -instantiated when a case is created. \ No newline at end of file +instantiated when a case is created. diff --git a/notes/do-work-behaviors.md b/notes/do-work-behaviors.md index 6ccb3554..35a4e9be 100644 --- a/notes/do-work-behaviors.md +++ b/notes/do-work-behaviors.md @@ -189,7 +189,7 @@ when a formal spec is drafted. directly or define a new Vultron-specific schema. DIOSTS is the preferred basis if Vultron aims for interoperability with the broader security community. -**Cross-reference**: `specs/embargo-policy.md` EP-01; `vultron/as_vocab/ +**Cross-reference**: `specs/embargo-policy.md` EP-01; `vultron/wire/as2/vocab/ objects/vultron_actor.py` (`VultronActorMixin.embargo_policy`). --- diff --git a/notes/domain-model-separation.md b/notes/domain-model-separation.md index dd5eb213..fafe3be3 100644 --- a/notes/domain-model-separation.md +++ b/notes/domain-model-separation.md @@ -118,19 +118,173 @@ constrain refactoring, specifically: be prioritized before the internal data model grows significantly more complex. Consider creating an ADR to record the decision formally before implementation. +## Domain Events as the Bridge Between Core and Wire + +When removing AS2 wire types from `core/behaviors/` (P65-6), the recommended +pattern is **per-semantic domain event types** rather than a single generic +`VultronEvent` class that mirrors the AS2 structure. + +The key insight: domain events only need to be defined for the things that +are represented by use cases — items corresponding to `MessageSemantics` +values or triggerable behaviors. Define specific named event classes such as +`ReportSubmittedEvent`, `CaseCreatedEvent`, `EmbargoAcceptedEvent` rather +than one large generic type. Each event class carries exactly the fields +needed for its specific use case. + +This approach: + +- Makes the translation point (wire → domain, domain → wire) explicit per + semantic type, rather than generic +- Supports the use-case-as-port pattern: adapters translate from AS2 activity + to a specific domain event, and from a domain event back to AS2 for outbound +- Avoids duplicating the full AS2 structure in the core while still retaining + rich semantic information +- Aligns the domain model with the `MessageSemantics` vocabulary, making the + relationship between wire format and domain intent explicit + +These domain event types belong in `core/models/` alongside `MessageSemantics`. +The outbound serializer in `wire/as2/serializer.py` will map each domain event +type to the appropriate AS2 activity type. + +### Naming Convention + +Wire-level and domain-level types MUST use distinct suffixes to prevent +accidental coupling (see `specs/code-style.md` CS-10-002): + +- Wire layer (`vultron/wire/as2/vocab/activities/`): `FooActivity` — the + structured AS2 payload the extractor recognizes (e.g., `ReportSubmitActivity`) +- Domain layer (`vultron/core/models/events/`): `FooEvent` — the typed + domain event handlers and use cases consume (e.g., `ReportSubmittedEvent`) + - Received-message flavour: `FooReceivedEvent` (remote actor did something) + - Trigger flavour: `FooTriggerEvent` (local actor initiated an action) + +The `events/` directory structure under `core/models/` SHOULD mirror the +`activities/` directory structure under `wire/as2/vocab/`, with submodules +grouped by semantic category (`report.py`, `case.py`, `embargo.py`, etc.). + +### Discriminated Event Hierarchy (P65-3 Design) + +To support type-safe handler dispatch, the domain event base class (`VultronEvent` +or equivalent) SHOULD carry a discriminator field based on `MessageSemantics`: + +```python +# core/models/events/base.py +from vultron.core.models.events import MessageSemantics +from pydantic import BaseModel + +class VultronEvent(BaseModel): + semantic_type: MessageSemantics # discriminator field + activity_id: str + actor_id: str + object_type: str | None = None + object_id: str | None = None + target_type: str | None = None + target_id: str | None = None + inner_object_type: str | None = None + inner_object_id: str | None = None +``` + +Specific event subclasses extend this base with fields relevant to their +use case. Pydantic's discriminated union functionality then allows the correct +subclass to be reconstructed from the generic base automatically. + +**Design principle**: Do not add fields speculatively. Derive the required fields +from a full audit of handler code (P65-3 task) to see exactly which AS2 fields +each handler accesses on `raw_activity`. Add only what the handlers actually need. + +The current `InboundPayload` model is the immediate precursor to this design. +The migration path is: + +1. Audit all `raw_activity` accesses across handler files (P65-3 audit step). +2. Enrich `InboundPayload` with the fields needed (replacing `raw_activity: Any`). +3. Define per-semantic subclasses and migrate handlers to use them (P65-3a). +4. Update the extractor to produce specific subclasses instead of a generic + payload (P65-3b). + +### P65-6a: `extract_intent()` Should Return a Discriminated Union + +Once per-semantic subclasses are defined (step 4 above), `extract_intent()` in +`wire/as2/extractor.py` SHOULD return a discriminated union of `VultronEvent` +subclasses rather than the flat `InboundPayload`. This allows the adapter layer +to pass a strongly-typed domain event directly to the dispatcher; the +`@verify_semantics` decorator continues to operate based on +`dispatchable.payload.semantic_type` without change. + +**Implementation notes (P65-6a)**: + +- `VultronEvent` base class lives in `core/models/events/base.py` with + `semantic_type: MessageSemantics` as the discriminator field. +- Per-semantic subclasses live in `core/models/events/` grouped by category + (`report.py`, `case.py`, `embargo.py`, etc.) following the `FooReceivedEvent` + suffix convention for inbound handler-side events (see CS-10-002). +- Do **not** add fields speculatively — include only what handler code actually + needs after the P65-3 audit. +- The wire layer adapter populates the correct subclass from the raw AS2 + activity; core handlers never see AS2 types. + +### Outbound Event Design Questions (P65-6 Considerations) + +Before implementing the outbound path (domain event → AS2 activity), consider: + +- **Which events go in `core/models/`?** Domain events corresponding to + outbound activities (e.g., `CaseCreatedEvent`, `EmbargoProposedEvent`). + These are distinct from inbound received events. +- **Outbound serializer mapping**: Should `wire/as2/serializer.py` map each + domain event type to AS2 one-to-one, or via a generic mapping table? + One-to-one is safer for type checking; a table is more compact. +- **Interplay with the outbox pipeline**: Domain events emitted by handlers + must eventually become AS2 activities written to the actor outbox. The + serializer is the seam between these two concerns (see `specs/outbox.md`). +- **ADR**: Consider drafting an ADR for the domain/wire separation decision + before implementation, to record the rationale. See `docs/adr/_adr-template.md`. + ## Cross-References - `specs/case-management.md` CM-03-006 — `case_statuses` rename requirement +- `specs/code-style.md` CS-10-001 — typed Pydantic objects at port/adapter boundaries +- `specs/code-style.md` CS-10-002 — `FooActivity` vs `FooEvent` naming convention - `notes/case-state-model.md` — CaseStatus/ParticipantStatus append-only history model - `notes/activitystreams-semantics.md` — `case_activity` type limitation, Accept/Reject `object` field patterns +- `notes/use-case-behavior-trees.md` — use case/BT layering and mapping from + protocol activities to use cases - `AGENTS.md` — pitfalls for `case_activity`, `active_embargo`, and `case_status` (singular) field - `docs/adr/_adr-template.md` — template for future ADR on this separation --- +## DataLayer as a Port, TinyDB as a Driven Adapter + +Independently of per-actor isolation, the `DataLayer` interface is treated as +a **port** in the hexagonal architecture sense, and the `TinyDbDataLayer` +implementation as a **driven adapter** that satisfies the port. + +This distinction matters even now, before per-actor isolation is implemented: + +- The port (Protocol interface) defines what the domain needs from persistence. +- The adapter (TinyDB) provides a concrete implementation behind that interface. +- A future MongoDB adapter would implement the same Protocol without requiring + core domain changes. + +**Current state (P65-1 complete)**: The `DataLayer` Protocol is defined in +`vultron/core/ports/activity_store.py`. The old location +(`vultron/api/v2/datalayer/abc.py`) is a backward-compat re-export shim. All +core BT nodes import `DataLayer` from `core/ports/`. Handlers receive the +`DataLayer` via dependency injection (achieved in ARCH-1.4). + +**Remaining step (P70)**: Relocate the `TinyDbDataLayer` implementation and +`get_datalayer()` factory from `vultron/api/v2/datalayer/` to +`vultron/adapters/driven/activity_store.py` (or equivalent) when P60-3 +(adapters package stub) is complete. + +**Design Decision**: The DataLayer relocation into the adapter layer SHOULD +be planned together with PRIORITY 100 (actor independence) and the potential +MongoDB switch. See the per-actor isolation options below. + +--- + ## Per-Actor DataLayer Isolation Options All actors currently share a singleton `TinyDbDataLayer` backed by a single diff --git a/notes/federation_ideas.md b/notes/federation_ideas.md index d94e6fdc..3fe071b0 100644 --- a/notes/federation_ideas.md +++ b/notes/federation_ideas.md @@ -8,14 +8,14 @@ ## Context A federated, open-source, containerized coordination service for multiparty case -handling (e.g., cross-vendor vulnerability tracking). Each organization runs +handling (e.g., cross-vendor vulnerability tracking). Each organization runs their own instance, which acts as a gateway between their proprietary internal tracker and a shared coordination protocol based on Activity Streams 2.0. Organizations don't need to know anything about each other's internal systems — only how to speak the shared protocol. -The model is closer to **email federation** (SMTP) or **Matrix homeservers** +The model is closer to **email federation** (SMTP) or **Matrix homeservers** than to Mastodon-style public ActivityPub. Federation is bilateral/multilateral between known, trusted peers, not open-web broadcast. @@ -118,12 +118,12 @@ CaseActor (is a full AS2 "Service" Actor) - Participants cannot message each other directly within a case — all communication routes through CaseActor. - This means: - - CaseActor can enforce authorization (Participant role controls what + - CaseActor can enforce authorization (Participant role controls what actions are permitted). - - CaseActor attests to ordering and delivery. - - Participants cannot spoof messages to each other. - - CaseActor must relay messages to participants (e.g., by `Announce`ing - them to the participant Actors as DMs), which adds a slight delay but + - CaseActor attests to ordering and delivery. + - Participants cannot spoof messages to each other. + - CaseActor must relay messages to participants (e.g., by `Announce`ing + them to the participant Actors as DMs), which adds a slight delay but ensures consistency. ### 7. Relay Pattern (Announce Extension) @@ -180,16 +180,16 @@ The CaseActor maintains two distinct collections, exposed as AS2 named streams: **Delivery Log** (`/streams/delivery`) -- Contains *Relay* (`Announce`) activities — the record of what was sent to +- Contains *Relay* (`Announce`) activities — the record of what was sent to whom and when. - Useful for debugging, retry tracking, and delivery receipt verification. - **Not** part of the sync protocol; not included in on-demand reconciliation. - Can be pruned or archived without affecting case integrity. -- Delivery Log will have a lot of noise compared to the Case Journal, - because the delivery log includes every relay to every participant, for - example, one - `Create(Note)` to 20 participants will be 1 `Create(Note)` Journal entry - but 1 `Create(Note)` followed by 20 `Announce(Create(Note))` Delivery Log +- Delivery Log will have a lot of noise compared to the Case Journal, + because the delivery log includes every relay to every participant, for + example, one + `Create(Note)` to 20 participants will be 1 `Create(Note)` Journal entry + but 1 `Create(Note)` followed by 20 `Announce(Create(Note))` Delivery Log entries. ### 9. Mirror Consistency Protocol @@ -197,15 +197,15 @@ The CaseActor maintains two distinct collections, exposed as AS2 named streams: - **Push by default**: CaseActor DMs all relevant Journal activities to participants as they occur, with `journalSeq` and `journalPrev` fields enabling immediate local ordering. -- **Non-repudiation**; Because each Journal entry contains `JournalPrev` - (hash of previous entry) and is signed by CaseActor, participants can +- **Non-repudiation**; Because each Journal entry contains `JournalPrev` + (hash of previous entry) and is signed by CaseActor, participants can verify the integrity and authenticity of the Journal stream as it arrives. -- **Gap detection**: participants track received sequence numbers (provided +- **Gap detection**: participants track received sequence numbers (provided by `journalSeq`) and detect gaps (e.g., received seq 1,2,3,5 → seq 4 is missing → trigger pull reconciliation). - **Pull reconciliation**: participants can fetch the CaseActor's `/outbox` (AS2 `OrderedCollection`, paginated) to resync at any time. This is the fallback, - not the primary path. CaseActor will need to enforce that only active + not the primary path. CaseActor will need to enforce that only active participants can fetch the Journal. ### 10. Instance Federation Model @@ -216,7 +216,7 @@ The CaseActor maintains two distinct collections, exposed as AS2 named streams: - Instances communicate via HTTPS with authenticated transport. - **Preferred transport auth**: mTLS (mutual TLS) between instances, handling authentication at the transport layer without per-message overhead. -- **Message-level authentication**: activities are signed with instance/actor +- **Message-level authentication**: activities are signed with instance/actor keypairs for non-repudiation independent of transport. ### 11. Instance Identity and Trust @@ -262,7 +262,7 @@ Proposed DNS-anchored trust model (analogous to DKIM + MX for email): - **Per-instance deduplication**: when CaseActor fans out to multiple participants on the same peer instance, delivery is deduplicated — one POST to the peer instance's shared inbox, not one per participant. -- In the prototype, delivery queue can be stubbed with synchronous delivery +- In the prototype, delivery queue can be stubbed with synchronous delivery underneath, but the architecture should allow for async delivery with retries in production. - Each instance exposes a **shared case inbox** (analogous to ActivityPub's `sharedInbox`) that receives activities on behalf of any local actor and @@ -289,77 +289,75 @@ When case ownership transfers, the CaseActor needs to move to (or be re-homed at) the new owning instance. Options: - **Migrate**: CaseActor URI changes, all participants notified of new address. - Clean but complex. Requires careful handling of in-flight messages during - the transition, and participants need to update their mirrors to point to + Clean but complex. Requires careful handling of in-flight messages during + the transition, and participants need to update their mirrors to point to the new CaseActor URI. - **Proxy**: Old instance forwards to new CaseActor. Simple but creates ongoing - dependency on old instance. Not recommended because it can create a + dependency on old instance. Not recommended because it can create a fragile chain of dependencies if ownership transfers multiple times. - **HTTP redirect + AS2 Move**: CaseActor URI stays canonical, new instance handles it, old instance returns HTTP 301. Borrows from ActivityPub actor migration. Probably most pragmatic. - **New CaseActor**: New instance creates a new CaseActor, old one is frozen. - Clean but loses continuity in the CaseActor's identity and history. - CaseActor key material would need to be regenerated, which could + Clean but loses continuity in the CaseActor's identity and history. + CaseActor key material would need to be regenerated, which could complicate signature verification across the ownership transfer. - - ### Participant Activity Routing / Filtering The Participant wrapper's role metadata should control which Journal activities each participant receives. The routing rules (e.g., observers get fewer updates than assignees) need to be formally specified. Where does this logic live — in -the Participant object itself, or in CaseActor policy? It would be more -consistent if the CaseActor had a role-based rule set that it applies when -deciding which activities to relay to which participants. Stubbing this out -in the prototype with a simple rule (e.g., all participants get all -activities) is fine, but the architecture should allow for more complex -rules in the future. At one level, the CaseActor just needs a consistent -`for participant in participants: send_to_participant(participant,activity)` +the Participant object itself, or in CaseActor policy? It would be more +consistent if the CaseActor had a role-based rule set that it applies when +deciding which activities to relay to which participants. Stubbing this out +in the prototype with a simple rule (e.g., all participants get all +activities) is fine, but the architecture should allow for more complex +rules in the future. At one level, the CaseActor just needs a consistent +`for participant in participants: send_to_participant(participant,activity)` and it's really internal to `send_to_participant()` how it decides whether -to actually send the activity or not based on the participant's role and the +to actually send the activity or not based on the participant's role and the activity type. ### Report Object Model -Reports are distinct from Cases. They are the object of the initial Offer +Reports are distinct from Cases. They are the object of the initial Offer that starts the process, and a Case is only created when the Offer is accepted. -Two potential formats are already known: plain text and CSAF-formatted JSON. +Two potential formats are already known: plain text and CSAF-formatted JSON. Other formats may be added in the future. -Unclear how to resolve when a Report is `Offer`ed to multiple recipients -simultaneously. Presumably in this situation each recipient could choose to -`Accept` then create a new Case from the same Report. This could be awkard -for the reporter, but it's a bit of an *own-goal* on the recipient's side to -submit the same Report to multiple recipients without receiving any feedback -from the first one. The protocol should allow for this to happen, but it is -expected to be rare in practice because it's not a great look for the -recipient to be doing this. (Instead, they should probably create their own -Case and invite multiple vendors to it, with the option to transfer case -ownership to one of the vendors in the future if they want to delegate the +Unclear how to resolve when a Report is `Offer`ed to multiple recipients +simultaneously. Presumably in this situation each recipient could choose to +`Accept` then create a new Case from the same Report. This could be awkard +for the reporter, but it's a bit of an *own-goal* on the recipient's side to +submit the same Report to multiple recipients without receiving any feedback +from the first one. The protocol should allow for this to happen, but it is +expected to be rare in practice because it's not a great look for the +recipient to be doing this. (Instead, they should probably create their own +Case and invite multiple vendors to it, with the option to transfer case +ownership to one of the vendors in the future if they want to delegate the coordination role.) However, -the multiple Offers from one report scenario does raise the question of +the multiple Offers from one report scenario does raise the question of how things -should work when a reporter makes an `Offer` that is unresponded to for a -period of time, and then the reporter decides to submit an `Offer` of the -same Report (should probably be a new `Offer` ID, so the "Offer ID" should -be unique to the report/recipient pair at least, if not just unique every -time it's submitted). The "vendor didn't respond, so I'll try asking a -coordinator for help" scenario is rather common in practice, much moreso +should work when a reporter makes an `Offer` that is unresponded to for a +period of time, and then the reporter decides to submit an `Offer` of the +same Report (should probably be a new `Offer` ID, so the "Offer ID" should +be unique to the report/recipient pair at least, if not just unique every +time it's submitted). The "vendor didn't respond, so I'll try asking a +coordinator for help" scenario is rather common in practice, much moreso than "I'll ask multiple vendors to coordinate this at the same time". -A case -merge mechanism might be a way to resolve this. If the second offer is -accepted followed by the first offer being accepted, then one of the cases -could be merged into the other. We would need to design exactly how one case -merges into another -- do they reconcile journals (this seems complex), or does -one case just get frozen into a read-only section of the other case and -perhaps leave some breadcrumb redirects behind to cause access to the frozen -case to be redirected to the merged case? The second option seems more -practical, but more specification will be necessary. +A case +merge mechanism might be a way to resolve this. If the second offer is +accepted followed by the first offer being accepted, then one of the cases +could be merged into the other. We would need to design exactly how one case +merges into another -- do they reconcile journals (this seems complex), or does +one case just get frozen into a read-only section of the other case and +perhaps leave some breadcrumb redirects behind to cause access to the frozen +case to be redirected to the merged case? The second option seems more +practical, but more specification will be necessary. ### Directory Service @@ -388,26 +386,25 @@ different orders temporarily. Is eventual consistency acceptable, or does the protocol need stronger ordering guarantees for multi-party coordination correctness? -Eventual consistency seems fine for now and we should probably make sure -that things work without requiring strong arrival ordering guarantees. -Note that the `journalSeq` fields permit participants to recognize that -they might be missing data if they observe discontinuities in the sequence, +Eventual consistency seems fine for now and we should probably make sure +that things work without requiring strong arrival ordering guarantees. +Note that the `journalSeq` fields permit participants to recognize that +they might be missing data if they observe discontinuities in the sequence, so they can trigger a pull sync to fill in the gaps. - ### Delivery Receipts Should participants send acknowledgment activities back to CaseActor on receipt? This would allow CaseActor's Delivery Log to record confirmed delivery vs. best-effort delivery, and enable smarter retry logic. Cost: additional message -volume. It seems like Receipts should probably be optional (MAY or SHOULD, not +volume. It seems like Receipts should probably be optional (MAY or SHOULD, not MUST). In the prototype, we can skip implementing Receipts and just log delivery attempts in the Delivery Log, but the architecture should allow for Receipts to be -added in the future without breaking the core protocol. Possible receipt -activity types: `Read` might be too strong (implies the participant actually -processed the activity), but a `Receive` or `Ack` activity could be a -lightweight confirmation that the activity was delivered to the -participant's inbox. +added in the future without breaking the core protocol. Possible receipt +activity types: `Read` might be too strong (implies the participant actually +processed the activity), but a `Receive` or `Ack` activity could be a +lightweight confirmation that the activity was delivered to the +participant's inbox. --- @@ -429,5 +426,5 @@ Candidate packages for implementation (not yet decided): Full ActivityPub server libraries (`bovine`, `pyfed`) are likely too opinionated for this use case. The above is a composable stack built around AS2 as a -*vocabulary* rather than full ActivityPub as a protocol, although some core +*vocabulary* rather than full ActivityPub as a protocol, although some core concepts from ActivityPub (actors, inboxes, outboxes) are still adopted as primitives. diff --git a/notes/use-case-behavior-trees.md b/notes/use-case-behavior-trees.md new file mode 100644 index 00000000..f31b8673 --- /dev/null +++ b/notes/use-case-behavior-trees.md @@ -0,0 +1,264 @@ +# Use Cases, Domain Logic, and Behavior Trees + +This note clarifies the relationship between **use cases**, **domain logic**, +and **behavior trees**, and proposes a module layout for organizing them. + +The goal is to keep: + +* orchestration logic simple +* domain rules centralized +* behavior policies explicit and testable + +--- + +# Conceptual Layering + +The system should follow this execution flow: + +``` +Driver (HTTP / CLI / protocol) + ↓ +Dispatcher + ↓ +Use Case + ↓ +Behavior Tree + ↓ +Domain Model + ↓ +Domain Events +``` + +Responsibilities of each layer: + +| Layer | Responsibility | +|---------------|---------------------------------------------| +| Driver | Accept external input (protocol, CLI, HTTP) | +| Dispatcher | Map protocol events to use cases | +| Use Case | Orchestrate a single actor goal | +| Behavior Tree | Evaluate domain policy and decide actions | +| Domain Model | Maintain state and enforce invariants | +| Domain Events | Record meaningful state transitions | + +--- + +# What a Use Case Is + +A **use case** represents an actor goal or system capability. + +Examples: + +``` +AddParticipantToCase +InviteActorToEmbargo +AcceptEmbargoInvitation +PublishAdvisory +``` + +Use cases should be **thin orchestration layers**. + +Typical structure: + +```python +class InviteActorToEmbargo: + + def execute(self, activity): + case = repo.load(activity.case_id) + + tree = EmbargoInviteTree(case) + + tree.run(activity) + + repo.save(case) +``` + +A use case should: + +* load aggregates +* invoke behavior logic +* persist results +* emit domain events + +A use case should **not contain complex business rules**. + +--- + +# Why Use Cases Sit Above Behavior Trees + +Use cases represent **external intentions**, while behavior trees represent * +*internal policy decisions**. + +Example: + +``` +Actor goal: + Invite participant + +System decisions: + Is the case open? + Is the actor trusted? + Is the invitation duplicate? + Should other participants be notified? +``` + +Those decisions belong in behavior trees. + +Therefore: + +``` +Use Case + triggers +Behavior Tree +``` + +This separation ensures: + +* policy logic is centralized +* use cases remain simple +* behavior can evolve without changing entry points + +--- + +# Behavior Trees + +Behavior trees implement domain policies. + +Example tree: + +``` +EmbargoInviteTree + +Selector + ├─ AlreadyInvited + ├─ CaseClosed + └─ AcceptInvite + ├─ AddInvitation + ├─ RecordAuditEvent + └─ NotifyParticipants +``` + +Nodes should only: + +* inspect domain state +* modify domain state +* emit domain events + +Nodes must **not perform infrastructure work**. + +--- + +# Event-Driven Behavior + +Behavior trees may also react to **domain events**. + +Example: + +``` +InvitationAccepted + → ParticipantOnboardingTree +``` + +Event-driven execution loop: + +``` +Domain Event + ↓ +Behavior Engine + ↓ +Run trees subscribed to event + ↓ +Modify domain state + ↓ +Emit new events +``` + +This allows coordination workflows to emerge from events rather than hard-coded +handlers. + +--- + +# Suggested Directory Layout + +Example structure: + +``` +core/ + + domain/ + vulnerability_case.py + embargo.py + + events/ + domain_events.py + + behavior/ + + engine.py + registry.py + + trees/ + embargo_invite_tree.py + embargo_accept_tree.py + publish_advisory_tree.py + + nodes/ + check_case_open.py + check_duplicate_invite.py + add_invitation.py + notify_participants.py + +application/ + + use_cases/ + invite_actor_to_embargo.py + accept_embargo_invitation.py + publish_advisory.py +``` + +Guidelines: + +* **domain/** contains aggregates and invariants +* **behavior/** contains policy logic +* **application/use_cases/** contains orchestration + +--- + +# Mapping Protocol Activities + +Protocol activities should map cleanly to use cases. + +Example: + +``` +Invite → InviteActorToEmbargo +Accept → AcceptEmbargoInvitation +Publish → PublishAdvisory +``` + +Use cases then invoke the appropriate behavior trees. + +This keeps protocol concerns separate from domain behavior. + +--- + +# Design Goals + +This structure provides: + +* clear separation of orchestration and policy +* explicit domain behavior +* easier testing of policy logic +* support for event-driven coordination + +The system becomes: + +``` +protocol event + → use case + → behavior tree + → domain state change + → domain events +``` + +This model is well-suited to **federated coordination systems** where many +independent actors interact through shared protocol events. diff --git a/plan/IDEAS.md b/plan/IDEAS.md index d5f6fd72..b2ae60fd 100644 --- a/plan/IDEAS.md +++ b/plan/IDEAS.md @@ -1,10 +1,2 @@ # Project Ideas -## Add MCP to `specs/agentic-readiness.md` - -We are going to want to allow for a local MCP server to access triggerable -behaviors and interact with the system in a way that parallels the API and -CLI adapters in the hexagonal architecture. We will need incorporate this -into `specs/agentic-readiness.md` to ensure that the necessary specifications -are in place to support this type of integration in the future without requiring -major refactoring. diff --git a/plan/IMPLEMENTATION_HISTORY.md b/plan/IMPLEMENTATION_HISTORY.md index d1254a83..8f01e5ed 100644 --- a/plan/IMPLEMENTATION_HISTORY.md +++ b/plan/IMPLEMENTATION_HISTORY.md @@ -292,3 +292,1074 @@ All 19 tasks completed. Key achievements: | Q8 | Health check ready conditions | Data layer connectivity only initially | | Q9 | Coverage enforcement | Threshold-based (80% overall, 100% critical paths) | | Q10 | Response generation timing | Defer decision until Phase 5 | + +--- + +## 2026-03-10 — SC-PRE-2 complete: actor_participant_index + +### Design + +`actor_participant_index: dict[str, str]` maps actor IDs (from +`CaseParticipant.attributed_to`) to participant IDs. Added to +`VulnerabilityCase` alongside two new methods: + +- `add_participant(participant: CaseParticipant)` — appends the full object + to `case_participants` and updates the index atomically. Requires a full + `CaseParticipant` object (not a string ref) to derive the actor key. +- `remove_participant(participant_id: str)` — filters `case_participants` + and removes the corresponding index entry. + +### Handlers updated + +- `add_case_participant_to_case` → calls `case.add_participant(participant)` +- `remove_case_participant_from_case` → calls `case.remove_participant(participant_id)` +- `accept_invite_actor_to_case` → calls `case.add_participant(participant)`; + idempotency check now uses `actor_participant_index` (old check was + comparing actor IDs against participant IDs and never matched). + +### Notes for SC-3.2 / SC-3.3 + +The `actor_participant_index` is the prerequisite for SC-3.2 and SC-3.3. +SC-3.2 records the accepted embargo ID in `CaseParticipant.accepted_embargo_ids` +using the CaseActor's trusted timestamp. The index makes it efficient to +look up a participant from the actor ID when processing `Accept(Invite(...))` or +`Accept(Offer(Embargo))` activities. + +SC-3.3 adds `_check_participant_embargo_acceptance()` as a module-level helper +in `vultron/api/v2/backend/handlers/case.py`. It is called from `update_case` +after the ownership check passes. The helper iterates `actor_participant_index`, +rehydrates each participant, and logs a WARNING if the participant's +`accepted_embargo_ids` does not include the case's `active_embargo` ID. +Full enforcement (withholding the broadcast) is deferred to PRIORITY-200 when +the outbox delivery pipeline is implemented. + +--- + +## 2026-03-10 — Gap analysis refresh #22: new gaps identified + + +### P70 DataLayer refactor — when to plan + +`notes/domain-model-separation.md` says the DataLayer relocation SHOULD be planned +together with PRIORITY-100 (actor independence). The P70 tasks in the plan follow +that guidance: P70-1 relocates the port Protocol and TinyDB adapter to their +correct architectural homes, which unblocks the per-actor isolation work in +PRIORITY-100. P60-3 must come first (adapters package stub needed before TinyDB +moves there). + +### TECHDEBT-4 superseded + +TECHDEBT-4 ("reorganize top-level modules `activity_patterns`, `semantic_map`, +`enums`") is largely complete: +- `vultron/activity_patterns.py` and `vultron/semantic_map.py` deleted in + ARCH-CLEANUP-1. +- AS2 structural enums moved from `vultron/enums.py` to `vultron/wire/as2/enums.py` + in ARCH-CLEANUP-2. +- `vultron/enums.py` now only re-exports `MessageSemantics` plus defines + `OfferStatusEnum` and `VultronObjectType`. + +Remaining work: move `OfferStatusEnum` and `VultronObjectType` to their proper +homes and delete `vultron/enums.py`. Tracked in TECHDEBT-4 (marked superseded in +plan) and P70-2. + +--- + +## 2026-03-09 — Hexagonal architecture refactor elevated to PRIORITY 50 (immediate next) + +Per updated `plan/PRIORITIES.md`, the hexagonal architecture refactor with `triggers.py` +as the starting point is now the top priority. The plan has been updated accordingly: +`Phase ARCH-1` is renamed to `Phase PRIORITY-50` and moved to be the immediate next +phase after PRIORITY-30 (now complete). The old "PRIORITY 150" label in the plan was +incorrect; PRIORITIES.md has always listed this as Priority 50. + + +--- + +## 2026-03-09 — P30-4 `close-report` vs `reject-report` distinction + +Both `reject-report` and `close-report` emit `RmCloseReport` (`as_Reject`), but +they differ in context: + +- `reject-report` hard-rejects an incoming report offer (offer not yet validated; + `object=offer.as_id`) +- `close-report` closes a report after the RM lifecycle has proceeded (RM → C + transition; emits RC message) + +The existing `trigger_reject_report` implementation uses `offer_id` as its target. +The `trigger_close_report` implementation should also use `offer_id` but should +validate that the offer's report is in an appropriate RM state for closure (not +just any offered report). This distinction should be documented in the endpoint +docstring. + +## 2026-03-09 — CS-09-002 duplication in triggers.py request models + +`ValidateReportRequest` and `InvalidateReportRequest` in `triggers.py` are +structurally identical (both have `offer_id: str` and `note: str | None`). Per +CS-09-002, these should be consolidated into a single base model with the other +as a subclass or alias. Low-priority but worth addressing when the file is next +modified. + +--- + +## 2026-03-09 — P30-4 complete: close-report trigger endpoint + +`POST /actors/{actor_id}/trigger/close-report` added. Emits `RmCloseReport` +(RM → C transition), updates offer/report status and actor outbox, returns +HTTP 409 if already CLOSED. 9 unit tests added. + +Also converted `RM` from plain `Enum` to `StrEnum` for consistency with `EM`. +This changes `str(RM.X)` from `"RM.REPORT_MANAGEMENT_X"` to `"X"`, resulting +in cleaner BT node names (e.g., `q_rm_in_CLOSED` instead of +`q_rm_in_RM.REPORT_MANAGEMENT_CLOSED`). Updated `test_conditions.py` to check +`state.value` in node name instead of `state.name`. + +--- + + +`specs/architecture.md` and `notes/architecture-review.md` were added since the +last plan refresh. The review identifies 11 violations (V-01 to V-11) and a +remediation plan (R-01 to R-06). The most impactful violations are: + +- **V-01**: `MessageSemantics` mixed with AS2 structural enums in `vultron/enums.py` +- **V-02**: `DispatchActivity.payload: as_Activity` (AS2 type leaks into core) +- **V-04**: `verify_semantics` decorator re-invokes `find_matching_semantics` + (second AS2-to-domain mapping point, violates Rule 4) + +Phase ARCH-1 now tracks this work. ARCH-1.1 (R-01) must be done before ARCH-1.2 +(R-02), which must precede ARCH-1.3 (R-03/R-04). + + + +### Approach for P50-0: Extract service layer from `triggers.py` — **COMPLETE** + +`triggers.py` is 1274 lines with all nine trigger endpoint functions each containing +inline domain logic (data lookups, state transitions, activity construction, outbox +updates). The fix is a two-step operation within one agent cycle: + +**Step 1 — Create `vultron/api/v2/backend/trigger_services/` package** ✓: +- `report.py` — service functions for `validate_report`, `invalidate_report`, + `reject_report`, `close_report` +- `case.py` — service functions for `engage_case`, `defer_case` +- `embargo.py` — service functions for `propose_embargo`, `evaluate_embargo`, + `terminate_embargo` + +Each service function signature: +```python +def svc_validate_report(actor_id: str, offer_id: str, note: str | None, dl: DataLayer) -> dict: + ... +``` +The `DataLayer` is passed in from the router (via `Depends(get_datalayer)`), not +fetched inside the service. + +**Step 2 — Thin-ify and split the router** ✓: +- Split `triggers.py` into `trigger_report.py`, `trigger_case.py`, + `trigger_embargo.py` in `vultron/api/v2/routers/` +- Each router function: validate request → call service → return response +- `triggers.py` deleted ✓ + +**Additional cleanup** ✓: +- Consolidated `ValidateReportRequest`, `InvalidateReportRequest`, and + `CloseReportRequest` (structurally identical — CS-09-002) into shared base + `ReportTriggerRequest` in `_models.py` +- Trigger tests split into `test_trigger_report.py`, `test_trigger_case.py`, + `test_trigger_embargo.py`; service-layer unit tests added in + `test/api/v2/backend/test_trigger_services.py` +- Test count: 777 → 815 passing + +### Why start with `triggers.py` before ARCH-1.1? + +ARCH-1.1 through ARCH-1.4 are deep structural changes affecting many files across +the codebase. P50-0 (triggers.py service extraction) is scoped to a single large +file, delivers immediate architectural value (no domain logic in routers), and does +not require the broader `core/`/`wire/`/`adapters/` directory restructure to be +in place first. It also establishes the pattern that ARCH-1.1 through 1.4 will +generalize to the rest of the codebase. + +ARCH-1.1 and ARCH-1.2 remain prerequisites for ARCH-1.3 and ARCH-1.4 as documented +in `notes/architecture-review.md`. + + +## Phase PRIORITY-50 — Hexagonal Architecture (archived 2026-03-10) + +All tasks complete, but active regressions V-02-R, V-03-R, V-10-R, V-11-R +remain. New violations V-13 through V-23 introduced in P60-2. See +`plan/PRIORITIES.md` Priority 65 and `notes/architecture-review.md`. + +- [x] **P50-0**: Extract domain service layer from `triggers.py`; split into three + focused router modules (`trigger_report.py`, `trigger_case.py`, + `trigger_embargo.py`) and a `trigger_services/` backend package. +- [x] **ARCH-1.1** (R-01): `MessageSemantics` moved to `vultron/core/models/events.py`. +- [x] **ARCH-1.2** (R-02): `InboundPayload` domain type introduced; AS2 type removed + from `DispatchActivity.payload`. *(regression V-02-R active)* +- [x] **ARCH-1.3** (R-03 + R-04): `wire/as2/parser.py` and `wire/as2/extractor.py` + created; parsing and extraction consolidated. +- [x] **ARCH-1.4** (R-05 + R-06): DataLayer injected via port; handler map moved to + adapter layer. *(regression V-10-R active)* +- [x] **ARCH-CLEANUP-1**: Shims deleted; all callers updated. +- [x] **ARCH-CLEANUP-2**: AS2 structural enums moved to `vultron/wire/as2/enums.py`. +- [x] **ARCH-CLEANUP-3**: `isinstance` checks against AS2 types removed. + *(regression V-11-R active: pattern unchanged via `raw_activity`)* +- [x] **ARCH-ADR-9**: `docs/adr/0009-hexagonal-architecture.md` written. + +--- + +## Phase PRIORITY-60 — Package Relocation (archived 2026-03-10) + +- [x] **P60-1**: `vultron/as_vocab/` → `vultron/wire/as2/vocab/` +- [x] **P60-2**: `vultron/behaviors/` → `vultron/core/behaviors/` + *(introduced violations V-13 through V-21 — addressed in P65)* +- [x] **P60-3**: `vultron/adapters/` package stub created. ✅ 2026-03-10 + +--- + +## Phase SPEC-COMPLIANCE-3 — Embargo Acceptance Tracking (archived 2026-03-10) + +- [x] **SC-PRE-2**: `actor_participant_index` added to `VulnerabilityCase`. +- [x] **SC-3.2**: Accepted embargo ID recorded in `CaseParticipant.accepted_embargo_ids`. +- [x] **SC-3.3**: Guard in `update_case` logs WARNING when participant has not accepted. + +--- + +## Technical Debt (housekeeping, archived 2026-03-10) + +- [x] **TECHDEBT-3**: Object IDs standardized to URI form; ADR-0010 created. ✅ +- [x] **TECHDEBT-4**: SUPERSEDED — shims deleted in ARCH-CLEANUP-1; remaining + `OfferStatusEnum`/`VultronObjectType` relocation deferred to P70. +- [x] **TECHDEBT-7/9**: `NonEmptyString`/`OptionalNonEmptyString` type aliases + introduced; empty-string validators replaced. ✅ 2026-03-10 +- [x] **TECHDEBT-8**: `pyrightconfig.json` committed; Makefile target added. ✅ +- [x] **TECHDEBT-10**: Pre-case events backfilled in case event log. ✅ +- [x] **TECHDEBT-11**: Test layout mirrored to match source after P60-1/P60-2. ✅ +- [x] **TECHDEBT-12**: Deprecated `HTTP_422_UNPROCESSABLE_ENTITY` replaced. ✅ + +## 2026-03-10 — P60-1 complete: vultron/as_vocab moved to vultron/wire/as2/vocab + +> ✅ Captured in `docs/adr/0009-hexagonal-architecture.md` (P60-1 marked +> complete) and `notes/codebase-structure.md` and +> `notes/architecture-ports-and-adapters.md` (file layout updated 2026-03-10). + +### What changed + +- Copied entire `vultron/as_vocab/` tree to `vultron/wire/as2/vocab/` (keeping + all sub-packages: `base/`, `objects/`, `activities/`, `examples/`, plus + `errors.py`, `type_helpers.py`). +- Updated all internal imports within the moved files from `vultron.as_vocab.*` + to `vultron.wire.as2.vocab.*`. +- Updated ~90 external callers across `vultron/api/`, `vultron/behaviors/`, + `vultron/demo/`, `vultron/wire/as2/`, and `test/`. +- Deleted `vultron/as_vocab/` entirely (no shim left behind). +- 822 tests pass. + +--- + +## 2026-03-10 — ARCH-CLEANUP-3 complete: isinstance AS2 checks replaced (V-11, V-12) + +> ✅ Captured in `notes/architecture-review.md` (V-11, V-12 marked remediated +> by ARCH-CLEANUP-3) and `docs/adr/0009-hexagonal-architecture.md` (2026-03-10). + +### What changed + +- **`handlers/report.py`**: `create_report` and `submit_report` now check + `dispatchable.payload.object_type != "VulnerabilityReport"` instead of + `isinstance`. `validate_report` uses `getattr(accepted_report, "as_type", None)`. + Local `VulnerabilityReport` imports removed from all three handlers. +- **`handlers/case.py`**: `update_case` uses `getattr(incoming, "as_type", None) == + "VulnerabilityCase"`. Local `VulnerabilityCase` import removed. +- **`trigger_services/report.py`**: `_resolve_offer_and_report` uses `getattr` + as_type check. `VulnerabilityReport` module-level import removed. +- **`trigger_services/_helpers.py`**: `resolve_case` and + `update_participant_rm_state` use `getattr` as_type checks. `VulnerabilityCase` + import retained for the `-> VulnerabilityCase` return type annotation. +- **`test/test_behavior_dispatcher.py`**: Removed `as_TransitiveActivityType` and + `VulnerabilityReport` imports. `as_type` assertion uses string `"Create"`. + Dispatch test uses `MagicMock` for `raw_activity` instead of full AS2 construction. +- **`test/api/test_reporting_workflow.py`**: `_call_handler` now populates + `InboundPayload.object_type` from `activity.as_object.as_type` (mirrors + `prepare_for_dispatch`), so handler type guards work correctly in tests. + +822 tests pass. + + + +### What changed + +- Deleted `vultron/activity_patterns.py`, `vultron/semantic_map.py`, and + `vultron/semantic_handler_map.py` (all were pure re-export shims). +- Updated all callers to import from canonical locations: + - `test/test_semantic_activity_patterns.py`: `ActivityPattern` and + `SEMANTICS_ACTIVITY_PATTERNS` now imported from `vultron.wire.as2.extractor`. + - `test/api/test_reporting_workflow.py`: `find_matching_semantics` now imported + from `vultron.wire.as2.extractor`. + - `test/test_semantic_handler_map.py`: Shim-specific tests removed; test now + uses `SEMANTICS_HANDLERS` from `vultron.api.v2.backend.handler_map` directly. +- 822 tests pass. + +--- + + + +### What changed + +- **`vultron/api/v2/backend/handler_map.py`** (new): Module-level handler registry in + the adapter layer. `SEMANTICS_HANDLERS` dict maps `MessageSemantics` → handler + functions with plain module-level imports (no lazy imports needed since this file + is already in the adapter layer). This addresses V-09. + +- **`vultron/semantic_handler_map.py`**: Converted to a backward-compat shim that + re-exports `SEMANTICS_HANDLERS` and a `get_semantics_handlers()` wrapper from + the new location. Can be deleted once all callers are updated. + +- **`vultron/behavior_dispatcher.py`**: `DispatcherBase` now accepts `dl: DataLayer` + and `handler_map: dict[MessageSemantics, BehaviorHandler]` in its constructor. + `_handle()` passes `dl=self.dl` to each handler. `_get_handler_for_semantics()` + uses `self._handler_map` directly (no lazy import). `get_dispatcher()` updated to + accept `dl` and `handler_map` parameters. + +- **`vultron/api/v2/backend/inbox_handler.py`**: Module-level `DISPATCHER` now + constructed with `get_datalayer()` + `SEMANTICS_HANDLERS` injected. The lazy + import in `_get_handler_for_semantics` is gone; the coupling to the handler map + now lives in the adapter layer only. + +- **All handler files** (`report.py`, `case.py`, `embargo.py`, `actor.py`, `note.py`, + `participant.py`, `status.py`, `unknown.py`): Each handler function signature + updated to `(dispatchable: DispatchActivity, dl: DataLayer) -> None`. All + `from vultron.api.v2.datalayer.tinydb_backend import get_datalayer` lazy imports + and `dl = get_datalayer()` calls removed. `DataLayer` imported at module level from + `vultron.api.v2.datalayer.abc`. This addresses V-10. + +- **`vultron/types.py`**: `BehaviorHandler` Protocol updated to + `__call__(self, dispatchable: DispatchActivity, dl: DataLayer) -> None`. + +- **`vultron/api/v2/backend/handlers/_base.py`**: `verify_semantics` wrapper updated + to accept and forward `dl: DataLayer`. + +- **Tests**: All `@patch("vultron.api.v2.datalayer.tinydb_backend.get_datalayer")` + patches in `test_handlers.py` replaced with direct `mock_dl` argument passing. + `test_behavior_dispatcher.py` updated to construct dispatcher with injected DL + and handler map. 824 tests pass (up from 822). + +### Phase PRIORITY-50 is now complete (ARCH-1.1 through ARCH-1.4) + +All four ARCH-1.x tasks are done. The hexagonal architecture violations V-01 through +V-10 have been remediated. The remaining violations in the inventory (V-11, V-12) +are lower severity and can be addressed as part of subsequent work. + +## 2026-03-09 — P30-6 complete: trigger sub-command in vultron-demo CLI + +Added `vultron-demo trigger` sub-command backed by `vultron/demo/trigger_demo.py`. + +Two end-to-end demo workflows are implemented: +- **Demo 1 (validate and engage)**: finder submits report via inbox → vendor + calls `POST .../trigger/validate-report` → vendor calls + `POST .../trigger/engage-case`. +- **Demo 2 (invalidate and close)**: finder submits report via inbox → vendor + calls `POST .../trigger/invalidate-report` → vendor calls + `POST .../trigger/close-report`. + +Supporting changes: +- Added `post_to_trigger()` helper to `vultron/demo/utils.py`. +- Added `trigger` demo to `DEMOS` list in `vultron/demo/cli.py`; it now runs + as part of `vultron-demo all`. +- Updated `docs/reference/code/demo/demos.md` and `cli.md` with new entries. + +Phase PRIORITY-30 is now fully complete (P30-1 through P30-6). + +--- + +## 2026-03-09 — ARCH-1.1 complete: MessageSemantics moved to vultron/core/models/events.py + +Created `vultron/core/` package with `models/events.py` containing only +`MessageSemantics`. Removed the definition from `vultron/enums.py` (which +now re-exports it for backward compatibility). Updated all 17 direct import +sites across `vultron/` and `test/`. 815 tests pass. + +The compatibility re-export in `vultron/enums.py` may be removed once ARCH-1.3 +consolidates the extractor and the AS2 structural enums move to +`vultron/wire/as2/enums.py` (R-04). + + +--- + +## 2026-03-09 — ARCH-1.2 complete: InboundPayload introduced; AS2 type removed from DispatchActivity + +Added `InboundPayload` to `vultron/core/models/events.py` with fields +`activity_id`, `actor_id`, `object_type`, `object_id`, and `raw_activity: Any`. +`DispatchActivity.payload` now types as `InboundPayload` instead of `as_Activity`, +removing the AS2 import from `vultron/types.py` (V-02) and from +`behavior_dispatcher.py` (V-03). All 38 handler functions updated to +`activity = dispatchable.payload.raw_activity`. `verify_semantics` decorator +updated to compare `dispatchable.semantic_type` directly (ARCH-07-001), removing +the second `find_matching_semantics` call. 815 tests pass. + +--- + +## 2026-03-09 — ARCH-1.3 complete: wire/as2/parser.py and wire/as2/extractor.py created + +### What moved + +- **`vultron/wire/as2/parser.py`** (new): `parse_activity()` extracted from + `vultron/api/v2/routers/actors.py`. Raises domain exceptions (`VultronParseError` + hierarchy defined in `vultron/wire/as2/errors.py` and `vultron/wire/errors.py`) + instead of `HTTPException`. The router now has a thin HTTP adapter wrapper that + catches these and maps to 400/422 responses (R-03, V-06, ARCH-08-001). + +- **`vultron/wire/as2/extractor.py`** (new): Consolidates `ActivityPattern` class, + all 37 pattern instances, `SEMANTICS_ACTIVITY_PATTERNS` dict, and + `find_matching_semantics()` from the former `vultron/activity_patterns.py` and + `vultron/semantic_map.py`. This is now the sole location for AS2-to-domain + semantic mapping (R-04, V-05, ARCH-03-001). + +- **`vultron/wire/errors.py`** (new): `VultronWireError(VultronError)` base. +- **`vultron/wire/as2/errors.py`** (new): `VultronParseError`, subtypes for missing + type, unknown type, and validation failure. + +### Backward-compat shims retained + +`vultron/activity_patterns.py` and `vultron/semantic_map.py` converted to +re-export shims so any external code importing from the old locations continues +to work. These can be deleted once confirmed no external callers remain. + +### What else changed + +- `vultron/behavior_dispatcher.py`: import `find_matching_semantics` from + `vultron.wire.as2.extractor` (no longer `vultron.semantic_map`). +- `vultron/api/v2/backend/inbox_handler.py`: removed `raise_if_not_valid_activity` + (V-07) and the `VOCABULARY` import; activity type validation now happens + entirely in the wire parser layer before the item reaches the inbox handler. +- Tests: `test_raise_if_not_valid_activity_raises` deleted; 7 new wire layer tests + added in `test/wire/as2/`. 822 tests pass (up from 815). + +## 2026-03-10 — P60-2: vultron/behaviors/ moved to vultron/core/behaviors/ + +> ✅ Captured in `docs/adr/0009-hexagonal-architecture.md` (P60-2 marked +> complete) and `notes/codebase-structure.md`, +> `notes/architecture-ports-and-adapters.md`, `notes/bt-integration.md` +> (all updated 2026-03-10). + +### What changed + +- Copied entire `vultron/behaviors/` tree (bridge, helpers, case/, report/) + to `vultron/core/behaviors/`. +- Updated all internal imports within the moved files from + `vultron.behaviors.*` to `vultron.core.behaviors.*`. +- Updated all external callers: + - `vultron/api/v2/backend/handlers/report.py` (lazy imports) + - `vultron/api/v2/backend/handlers/case.py` (lazy imports) + - `vultron/api/v2/backend/trigger_services/report.py` + - 8 test files under `test/behaviors/` +- Deleted `vultron/behaviors/` entirely (no shim retained; all callers + updated in the same step). +- 822 tests pass. + +--- + + + + +## TECHDEBT-9/7 — NonEmptyString type alias rollout (2026-03-10) + +`NonEmptyString` and `OptionalNonEmptyString` were already defined in +`vultron/wire/as2/vocab/base/types.py` and partially applied. This task +completed the rollout across all remaining `Optional[str]` fields in +`vultron/wire/as2/vocab/objects/`: + +- **`case_event.py`**: Replaced per-field `@field_validator` on `object_id` + and `event_type` with `NonEmptyString` type annotations; removed validators. +- **`case_reference.py`**: Replaced per-field validators for `url` and `name` + with `NonEmptyString` and `OptionalNonEmptyString`; removed validators. +- **`vulnerability_record.py`**: Changed `url: str | None` to + `OptionalNonEmptyString`. +- **`case_participant.py`**: Changed `name` and `participant_case_name` from + `str | None` to `OptionalNonEmptyString`. +- **`case_status.py`**: Changed `CaseStatus.context` and + `ParticipantStatus.tracking_id` from `str | None` to `OptionalNonEmptyString`. + +Error message updated: tests that previously asserted field-prefixed messages +(e.g., "object_id must be a non-empty string") now assert the shared message +"must be a non-empty string" (which the `AfterValidator` in `_non_empty` raises). + +New tests added: `test_case_status.py`, extended `test_case_participant.py`, +extended `test_vulnerability_record.py`. 860 tests pass. + +Note: `CaseParticipant.set_name_if_empty` model validator automatically +populates `name` from `attributed_to` when `name=None`; tests for `name=None` +must omit `attributed_to` to observe the None value. + +## TECHDEBT-10 — Backfill pre-case events in create_case BT (2026-03-10) + +**Task**: Backfill pre-case events into the case event log at case creation +(CM-02-009). + +**Implementation**: + +- Added `RecordCaseCreationEvents` node to + `vultron/core/behaviors/case/nodes.py`. The node runs after `PersistCase` in + the `CreateCaseFlow` sequence. +- The node records two events using `case.record_event()`: + 1. `"offer_received"` — only when the triggering activity has an + `in_reply_to` reference (the originating Offer that led to case + creation). The `object_id` is set to the Offer's `as_id`. + 2. `"case_created"` — always recorded; `object_id` is set to the case ID. +- The node reads `activity` from the global py_trees blackboard storage + (`Blackboard.storage.get("/activity", None)`) rather than registering it as + a required key. This avoids a `KeyError` when the tree is invoked without an + inbound activity (e.g. in tests that pass `activity=None`). +- `create_tree.py` updated to import and include `RecordCaseCreationEvents` in + the sequence. +- 6 new tests added to `test/core/behaviors/case/test_create_tree.py`. + +**Key design note**: `received_at` in `CaseEvent` is set by +`default_factory=_now_utc`, satisfying CM-02-009's trusted-timestamp +requirement automatically. The node never copies a timestamp from the +incoming activity. + +**866 tests pass.** + +## TECHDEBT-8 — Pyright gradual static type checking (2026-03-10) + +**Task**: Configure pyright for gradual static type checking (IMPL-TS-07-002). + +**Implementation**: + +- Added `pyright` to `[dependency-groups].dev` in `pyproject.toml`. +- Created `pyrightconfig.json` at the repo root with `typeCheckingMode: "basic"`, + targeting `vultron/` and `test/`, Python 3.12, `reportMissingImports: true`, + `reportMissingTypeStubs: false`. +- Added `pyright` target to `Makefile` (`uv run pyright`). + +**Baseline error count (2026-03-10, pyright 1.1.408, basic mode)**: + +``` +811 errors, 7 warnings, 0 informations +``` + +These errors are pre-existing technical debt and are NOT blocking. They will +be resolved incrementally as part of ongoing development. New and modified +code should be made clean under pyright basic mode before merging. + +**Key error categories observed**: +- `reportInvalidTypeArguments`: `Optional[str]` spelled as `str | None` used + as type argument (Pydantic `Annotated` patterns) — widespread across + `wire/as2/vocab/objects/`. +- `reportAttributeAccessIssue` / `reportOptionalMemberAccess`: Union types + narrowed incorrectly in property implementations. +- `reportGeneralTypeIssues`: Field override without default value. + +--- +## 2026-03-10 — P65-2 complete (marked; done in P65-1 commit) + +P65-2 was implemented in the same commit as P65-1. The `inbox_handler.py` +already used `_DISPATCHER: ActivityDispatcher | None = None` at module level, +`init_dispatcher(dl)` for lifespan injection, and both `main.py` and `app.py` +call `init_dispatcher(dl=get_datalayer())` in their lifespan contexts. +No `get_datalayer()` call appears at module level or inside `dispatch()`. + +## 2026-03-10 — P65-5 complete + +### What was done + +- Created `vultron/core/models/status.py` containing `ObjectStatus`, + `OfferStatus`, `ReportStatus`, `STATUS`, `set_status`, `get_status_layer`, + and `status_to_record_dict`. These were previously defined in the + adapter-layer `api/v2/data/status.py`. +- Replaced `vultron/api/v2/data/status.py` with a backward-compat re-export + shim pointing to `core/models/status`. +- Added `save_to_datalayer(dl, obj)` helper to `core/behaviors/helpers.py`. + This constructs a `StorableRecord` from `obj.as_id`, `obj.as_type`, and + `obj.model_dump(mode="json")` then calls `dl.update()`. Avoids importing + the adapter-layer `Record`/`object_to_record` in core BT nodes. +- Updated `core/behaviors/report/nodes.py`: replaced `api/v2/data/status` + and `api/v2/datalayer/db_record` imports with `core/models/status` and + `core/behaviors/helpers`; replaced all `object_to_record` calls with + `save_to_datalayer`; removed lazy imports at old lines 744–745. +- Updated `core/behaviors/case/nodes.py`: same pattern for `object_to_record`. + +### What was NOT done (deferred to P65-6) + +AS2 wire type imports (`VulnerabilityCase`, `CreateCase`, `CaseActor`, +`VendorParticipant`) remain in the core BT nodes — their removal requires +defining domain event types and an outbound serialiser (P65-6). The `ParticipantStatus` +lazy import was converted to a local import inside `_find_and_update_participant_rm` +(still a local import, but now the only remaining local import, and it +references a wire-layer type that will be addressed in P65-6). + +### Violations addressed + +- V-14 (Record): No core BT node imports `Record` or `object_to_record`. +- V-16: `core/behaviors/report/nodes.py` no longer imports `OfferStatus` + from the adapter layer. +- V-18 partial: Adapter-level `object_to_record` removed from core BT nodes. + + +## P65-3: Enrich InboundPayload; Eliminate raw_activity + +**Completed**: hexagonal-refactor branch + +### Summary + +Removed `raw_activity: Any` from `InboundPayload` (core domain type) and +replaced it with 13 typed domain fields (all `str | None`): +`activity_type`, `target_id`, `target_type`, `context_id`, `context_type`, +`origin_id`, `origin_type`, `inner_object_id`, `inner_object_type`, +`inner_target_id`, `inner_target_type`, `inner_context_id`, `inner_context_type`. + +Added `wire_activity: Any` and `wire_object: Any` to `DispatchActivity` (adapter +layer) so handlers that persist AS2 objects can still do so without polluting the +core domain. + +Added `extract_intent()` to `vultron/wire/as2/extractor.py` — the sole +AS2→domain mapping point — which returns `(MessageSemantics, InboundPayload)` +with all fields populated from the AS2 object graph. + +Updated all 7 handler files (`report.py`, `case.py`, `actor.py`, `embargo.py`, +`note.py`, `participant.py`, `status.py`) to read exclusively from +`InboundPayload` fields and `dispatchable.wire_activity`/`wire_object`. + +**Violations addressed**: V-02-R, V-11-R. +**Result**: 880 tests pass, 0 regressions. + +--- + +## P65-4 — Decouple `behavior_dispatcher.py` from the wire layer (2026-03-11) + +Moved `prepare_for_dispatch()` from `vultron/behavior_dispatcher.py` to the +adapter layer (`vultron/api/v2/backend/inbox_handler.py`). The wire-layer +imports (`find_matching_semantics`, `extract_intent`) and the `Any` typing +import were removed from `behavior_dispatcher.py`. The `extract_intent` import +now lives in `inbox_handler.py`, the single adapter-layer entry point. + +Moved the `test_prepare_for_dispatch_*` test from +`test/test_behavior_dispatcher.py` to +`test/api/v2/backend/test_inbox_handler.py` (adapter layer). Removed the +`as_Create` wire import and redundant `MessageSemantics = bd.MessageSemantics` +re-assignment from `test_behavior_dispatcher.py`. + +**Violations addressed**: V-03-R. +**Result**: 880 tests pass, 0 regressions. + +--- + +## P65-6a — VultronEvent typed event hierarchy (2026-03-11) + +**Task**: Define `VultronEvent` base class and per-semantic inbound domain event +subclasses in `core/models/events/`. + +**What was done**: + +- Converted `vultron/core/models/events.py` to a package (`events/`) with the + following structure: + - `base.py`: `MessageSemantics` enum, `NonEmptyString`/`OptionalNonEmptyString` + helpers, and the `VultronEvent` Pydantic base class (same 17 fields as the + former `InboundPayload` plus the required `semantic_type: MessageSemantics` + discriminator field). + - `report.py`, `case.py`, `actor.py`, `case_participant.py`, `embargo.py`, + `note.py`, `status.py`, `unknown.py`: per-semantic `FooReceivedEvent` + subclasses, each setting `semantic_type: Literal[MessageSemantics.X]`. + Covers all 38 semantics + `UNKNOWN`. + - `__init__.py`: exports all types, `EVENT_CLASS_MAP` (dict mapping each + `MessageSemantics` to its concrete class), and the backward-compat alias + `InboundPayload = VultronEvent`. +- Updated `extract_intent()` in `wire/as2/extractor.py` to return a single + typed `VultronEvent` subclass (not a `(MessageSemantics, InboundPayload)` + tuple). Uses `EVENT_CLASS_MAP` to construct the correct subclass. +- Updated `DispatchActivity.payload` type in `vultron/types.py` from + `InboundPayload` to `VultronEvent`. +- Updated `prepare_for_dispatch()` in `inbox_handler.py` to unpack + `event = extract_intent(activity)` and read `event.semantic_type`. +- Removed redundant `object_type` string guards from `create_report`, + `submit_report`, and `validate_report` handlers (guaranteed by semantic + pattern matching). +- Updated `test_behavior_dispatcher.py` to use `CreateReportReceivedEvent` + instead of `InboundPayload`. +- Updated `_make_payload` in `test_handlers.py` to auto-derive `semantic_type` + via `find_matching_semantics()` and return the correctly typed subclass via + `EVENT_CLASS_MAP`. +- Updated `_call_handler` in `test_reporting_workflow.py` to use the new + single-value `extract_intent()` return. + +**Violations addressed**: V-02-R follow-on (typed domain events replace generic +payload; extractor now returns discriminated subclasses). + +**Result**: 880 tests pass, 0 regressions. + +--- + +## P65-6b — Remove AS2 wire imports from core/behaviors/ (R-09 part 2) + +**Files created**: + +- `vultron/core/models/vultron_types.py`: Rich domain types — `VultronCase`, + `VultronReport`, `VultronCaseActor`, `VultronParticipant`, + `VultronCreateCaseActivity`, `VultronParticipantStatus`, `VultronCaseStatus`, + `VultronCaseEvent`. Each mirrors the Vultron-specific fields of its wire + counterpart, using `str` IDs for cross-references and clean Python enums. + `as_type` strings match wire values for DataLayer round-trip compatibility. +- `vultron/wire/as2/serializer.py`: Outbound serializer converting domain → + wire types for adapter-layer use. Core BT nodes do NOT import this. + +**Files modified**: + +- `vultron/core/behaviors/case/nodes.py`: Wire imports replaced with domain + types; `CVDRoles.VENDOR` role set explicitly on `VultronParticipant`. +- `vultron/core/behaviors/case/create_tree.py`: `VulnerabilityCase` → + `VultronCase` type annotation. +- `vultron/core/behaviors/report/nodes.py`: Wire imports replaced; field name + `actor` → `attributed_to` in `VultronParticipantStatus` construction. +- `vultron/core/behaviors/report/policy.py`: Wire imports fully replaced with + `VultronCase`/`VultronReport`. + +**Key decisions**: + +- Domain types are rich (mirror Vultron-specific fields), not thin stubs. +- `CVDRoles` serialization uses `@field_serializer` returning `.name` strings, + matching the wire `CaseParticipant` convention. +- `VultronParticipantStatus` appended to wire `CaseParticipant.participant_statuses` + works via Pydantic v2 duck-typing (list.append bypasses field validation; + round-trip through `model_dump(mode="json")` + `model_validate` succeeds). +- `_now_utc()` uses stdlib `datetime.now(timezone.utc)` to avoid importing + `now_utc` from the wire layer. + +**Violations addressed**: V-15 (full), V-17, V-18 (full), V-19. + +**Result**: 880 tests pass, 0 regressions. + +--- + +## P65-7 — Remove wire AS2 imports from core BT test files (commit 59e85ca) + +**Goal**: No `core/behaviors/` test file imports wire-layer AS2 types. + +**Changes**: + +- `vultron/core/models/vultron_types.py`: Added `VultronOffer`, `VultronAccept`, + `VultronOutbox` domain types. Added `outbox: VultronOutbox` field to + `VultronCaseActor` (required for `UpdateActorOutbox` BT node's + `save_to_datalayer` call). Widened `VultronCase.case_participants` to + `list[str | VultronParticipant]`. +- `test/core/behaviors/report/test_nodes.py`: Replaced `as_Service`, + `VulnerabilityReport`, `as_Offer` with domain equivalents. +- `test/core/behaviors/report/test_validate_tree.py`: Same substitutions. +- `test/core/behaviors/report/test_prioritize_tree.py`: Replaced + `VulnerabilityCase`, `CaseParticipant`, `VulnerabilityReport`, `as_Service`. +- `test/core/behaviors/case/test_create_tree.py`: Replaced all wire imports; + removed unused `CaseActor`/`VendorParticipant` imports. +- `test/core/behaviors/test_performance.py`: Replaced all top-level wire + imports; `mock_read` returns `VultronCaseActor` for actor IDs (previously + `MagicMock`, which broke `model_dump()` in `save_to_datalayer`). + +**Violations addressed**: V-22 (partial → complete resolution pending P65-4), +V-23 (full). + +**Result**: 880 tests pass, 0 regressions. + +--- + +## ARCH-DOCS-1 — Update architecture-review.md violation status markers + +**Date**: 2026-03-11 +**Commit**: d19252a + +Updated `notes/architecture-review.md` to accurately reflect the post-P65-7 +state of the codebase. All violations V-01 through V-23 are now marked as +fully resolved. + +**Changes made**: +- Status header block: added new paragraph summarising P65-4, P65-6b, and P65-7 + completions; declared all V-01–V-23 resolved. +- Section headers for "Active Regressions" and "New Violations" updated with + "All Resolved ✅" suffixes. +- V-03-R: heading updated to ✅ RESOLVED (P65-4); body replaced plan text with + what was done. +- V-15, V-16, V-18: heading updated from ⚠️ PARTIALLY RESOLVED to ✅ RESOLVED + (P65-6b); full resolution text appended. +- V-17, V-19: heading updated to ✅ RESOLVED (P65-6b); resolution text added. +- V-22, V-23: heading updated to ✅ RESOLVED (P65-7); resolution text added. +- R-09: updated from ⚠️ PARTIALLY COMPLETE to ✅ COMPLETE; replaced remaining + work list with what-was-done summary. +- R-10: updated to ✅ COMPLETE; replaced plan text with outcome summary. +- R-11: updated to ✅ COMPLETE inline (previously had no status marker). + +**Result**: 880 tests pass, 0 regressions. No code changes — documentation only. + +--- + +## TECHDEBT-13a — Wire-boundary cleanup: test_policy.py (COMPLETE 2026-03-11) + +Replaced `VulnerabilityReport` import (from `vultron.wire.as2.vocab.objects`) +with `VultronReport` (from `vultron.core.models.vultron_types`) in +`test/core/behaviors/report/test_policy.py`. This eliminates the residual V-23 +violation where a core-layer test imported a wire-layer type. Tests pass via +duck-typing since `VultronReport` has the same fields (`as_id`, `name`, +`content`) as the wire-layer type the policy module already expects. + +**Result**: 880 tests pass, 0 regressions. No production code changes. + +--- + +## TECHDEBT-13b/c — Wire-boundary cleanup: _base.py and TYPE_CHECKING imports (COMPLETE 2026-03-11) + +**TECHDEBT-13b**: Updated `vultron/wire/as2/vocab/examples/_base.py` to remove +all adapter-layer imports. The module-level `DataLayer` annotation now imports +from `vultron.core.ports.activity_store`; `initialize_examples()` requires an +explicit `DataLayer` argument (removed `None` default, `get_datalayer()` +fallback, and `Record.from_obj()` usage). Objects are passed directly to +`datalayer.create()` since the `DataLayer` protocol accepts `BaseModel`. + +**TECHDEBT-13c**: Updated `TYPE_CHECKING` guard imports in `vultron/types.py` +and `vultron/behavior_dispatcher.py` to reference +`vultron.core.ports.activity_store.DataLayer` directly instead of the +`vultron.api.v2.datalayer.abc` shim. + +**Result**: 880 tests pass, 0 regressions. V-24 fully resolved. + +--- + +## P70-3 — Add core/ports/delivery_queue.py and dns_resolver.py Protocol stubs (COMPLETE 2026-03-11) + +Added two Protocol stub files to `vultron/core/ports/`: + +- **`delivery_queue.py`**: `DeliveryQueue` Protocol with `enqueue(activity_id, recipient_id) -> None` + and `drain() -> int`. Mirrors the outbound delivery contract referenced by + `vultron/adapters/driven/delivery_queue.py`. +- **`dns_resolver.py`**: `DnsResolver` Protocol with `resolve_txt(domain) -> list[str]`. + Mirrors the trust-discovery contract referenced by + `vultron/adapters/driven/dns_resolver.py`. + +Both files contain only `Protocol` class definitions with no adapter-layer imports, +following the pattern established by `vultron/core/ports/activity_store.py`. + +**Result**: 880 tests pass, 0 regressions. Both driven adapter stubs import cleanly. + +--- + +## P70-2 — Relocate OfferStatusEnum and VultronObjectType to core (COMPLETE 2026-03-11) + +Moved both domain-boundary enums out of `vultron/enums.py` into their correct +architectural homes and deleted the now-empty shim: + +- **`OfferStatusEnum`** → `vultron/core/models/status.py` (defined before + `ObjectStatus` which uses it; removed separate import) +- **`VultronObjectType`** → new `vultron/core/models/enums.py` (wire layer + must import from core, not define its own parallel enum) +- **`vultron/enums.py`** deleted (all three symbols now imported directly from + their canonical locations; `MessageSemantics` was already imported directly + from `vultron.core.models.events` by all callers) + +Updated 13 caller files across `vultron/wire/as2/`, `vultron/api/v2/`, +`vultron/core/`, and `test/`. + +**Result**: 880 tests pass, 0 regressions. No `vultron.enums` imports remain. + +--- + +## P70-4 — Move TinyDbDataLayer to adapters/driven/ (2026-03-11) + +**Task**: Move `vultron/api/v2/datalayer/tinydb_backend.py` (the TinyDB +implementation) to `vultron/adapters/driven/activity_store.py` and leave +a backward-compat re-export shim at the old path. + +**What was done**: + +- Populated `vultron/adapters/driven/activity_store.py` (formerly a stub + docstring) with the full `TinyDbDataLayer` class, `get_datalayer()`, and + `reset_datalayer()`. Updated the `DataLayer` import to reference + `vultron.core.ports.activity_store` directly instead of the `abc.py` shim. +- Replaced `vultron/api/v2/datalayer/tinydb_backend.py` with a one-file + backward-compat shim that re-exports `TinyDbDataLayer`, `get_datalayer`, + and `reset_datalayer` from `vultron.adapters.driven.activity_store`. +- All existing callers of the old path continue to work via the shim; no + other files were modified. + +**Result**: 880 tests pass, 0 regressions. + +**Next**: P70-5 — remove shims and update all remaining callers to import +from `adapters/driven/` directly. + +--- + +### P70-5 — Remove DataLayer shims (2026-03-12) + +**Task**: Remove backward-compat shims `api/v2/datalayer/abc.py`, +`api/v2/datalayer/tinydb_backend.py`, and `api/v2/datalayer/db_record.py`; +update all callers to import from canonical locations. + +**What was done**: + +- Moved `vultron/api/v2/datalayer/db_record.py` to + `vultron/adapters/driven/db_record.py`. +- Updated `vultron/adapters/driven/datalayer_tinydb.py` to import `Record`, + `object_to_record`, and `record_to_object` from the new local path. +- Bulk-updated all `vultron/` and `test/` files (≈50 files) with `sed`: + - `vultron.api.v2.datalayer.abc.DataLayer` → `vultron.core.ports.datalayer.DataLayer` + - `vultron.api.v2.datalayer.tinydb_backend.*` → `vultron.adapters.driven.datalayer_tinydb.*` + - `vultron.api.v2.datalayer.db_record.*` → `vultron.adapters.driven.db_record.*` +- Deleted `abc.py`, `tinydb_backend.py`, and `db_record.py` from + `vultron/api/v2/datalayer/`. +- No module now imports from `vultron.api.v2.datalayer.*`. + +**Result**: 880 tests pass, 0 regressions. + +**Next**: P75-1 — define `VultronEvent` domain event types in `core/models/events.py`. + +--- + +## P75-1 — VultronEvent domain event types (verified complete 2026-03-12) + +**Task**: Define `VultronEvent` domain event base type and all 38 per-semantic +concrete subclasses in `vultron/core/models/events/`, with no wire or adapter +imports. + +**What was done** (completed as part of P65-6a, verified now): + +- `vultron/core/models/events/` package exists with: + - `base.py`: `MessageSemantics` (39 values), `VultronEvent` base class, + `NonEmptyString`, `OptionalNonEmptyString` + - Per-category modules: `actor.py`, `case.py`, `case_participant.py`, + `embargo.py`, `note.py`, `report.py`, `status.py`, `unknown.py` + - `__init__.py`: `EVENT_CLASS_MAP` (all 39 semantics mapped), `InboundPayload` + backward-compat alias for `VultronEvent` +- No wire-layer or adapter-layer imports anywhere in `core/models/events/` +- All 39 `MessageSemantics` values covered by the `EVENT_CLASS_MAP` + +**Result**: 880 tests pass, 0 regressions. + +**Next**: P75-2 — extract handler business logic from +`vultron/api/v2/backend/handlers/` into `vultron/core/use_cases/`. + +--- + +## P75-2 — Extract Handler Business Logic to Core Use Cases + +**Completed**: 2026-03-13 + +**What was done**: Extracted all 38 handler functions from +`vultron/api/v2/backend/handlers/` into 8 new use-case modules under +`vultron/core/use_cases/`. Handlers are now thin delegates that call +`uc.func(dispatchable.payload, dl)`. + +**Supporting changes**: +- Added `VultronActivity`, `VultronNote`, `VultronEmbargoEvent` domain + types to `vultron/core/models/vultron_types.py` +- Enriched all `VultronEvent` subclasses with optional typed domain-object + fields (`report`, `case`, `embargo`, `note`, `participant`, `activity`, + `status`) populated by `extract_intent()` in the wire layer +- Added `in_reply_to` field and `as_id` property to `VultronEvent` base + (BT bridge nodes read `activity.as_id` and `activity.in_reply_to`) +- Added `DataLayer.save(obj: BaseModel)` port method (upsert semantics) + implemented by `TinyDbDataLayer`; use cases call `dl.save()` instead of + `dl.update(id, object_to_record(obj))` +- Added `CaseModel` and `ParticipantModel` Protocols in + `vultron/core/use_cases/_types.py` for type-safe duck-typed access to + DataLayer results +- Use cases call `dl.read()` instead of `rehydrate()` for object loading; + handlers pass `wire_object`/`wire_activity` as optional kwargs where + DataLayer round-trip would lose wire subtype information + +**Result**: 880 tests pass, 0 regressions. + +**Next**: P75-3 — migrate trigger-service logic to `vultron/core/use_cases/`. + +--- + +## P75-2a — Core Domain Model Audit and Enrichment (2026-03-13) + +**Task**: Audit every `Vultron*` domain type in `vultron/core/models/vultron_types.py` +against its wire counterpart, add missing semantically relevant fields, update +`extract_intent()` to populate them, and add round-trip tests. + +**Fields added per domain type**: +- `VultronCaseStatus`: `name` +- `VultronParticipantStatus`: `name`, `case_status` (case status ID ref); `vfd_state` + was already present in the domain model but not populated by `extract_intent()` — + that gap was also fixed. +- `VultronReport`: `summary`, `url`, `media_type`, `published`, `updated` +- `VultronCase`: `url`, `published`, `updated` +- `VultronActivity`: `origin` +- `VultronNote`: `summary`, `url` +- `VultronEmbargoEvent`: `published`, `updated` + +**extract_intent() changes**: All new fields are now populated. Additionally, +`VultronParticipant` extraction was extended to populate `case_roles` and +`participant_case_name` from the incoming wire `CaseParticipant` (these fields were +already in the domain model but not wired up). `VultronActivity.origin` is now +populated from the wire activity's `origin` field. + +**Tests**: 8 new tests added to `test/wire/as2/test_extractor.py` verifying +wire-to-domain round-trips for each new field category. + +**Intentionally excluded** (AS2 boilerplate not relevant for domain logic): +`as_context`, `preview`, `replies`, `generator`, `icon`, `image`, `attachment`, +`location`, `to`, `cc`, `bto`, `bcc`, `audience`, `duration`, `tag`, `in_reply_to` +(except where already present), `instrument`, `result`. + +**Result**: 888 tests pass (880 prior + 8 new), 0 regressions. + +**Next**: P75-2b — remove wire coupling from dispatch envelope, rename +`DispatchActivity` → `DispatchEvent`. + +--- + +## P75-2b — Remove wire coupling from dispatch envelope (2026-03-13) + +**Task**: Rename `DispatchActivity` → `DispatchEvent`, remove `wire_activity` +and `wire_object` fields from the dispatch envelope, and eliminate wire-object +pass-through parameters from all use case functions. + +**Changes**: + +- `vultron/types.py`: Renamed `DispatchActivity` → `DispatchEvent`, removed + `wire_activity: Any` and `wire_object: Any` fields, removed + `ConfigDict(arbitrary_types_allowed=True)`, updated `BehaviorHandler` + Protocol; added backward-compat alias `DispatchActivity = DispatchEvent`. +- `vultron/behavior_dispatcher.py`: Updated 3 references. +- `vultron/api/v2/backend/inbox_handler.py`: Removed `wire_object` extraction, + updated `DispatchEvent()` constructor. +- All 8 handler files: Updated imports and annotations. +- All 6 use case files: Removed `wire_object`/`wire_activity` params; replaced + fallback logic with direct `event.X` access. +- `vultron/wire/as2/extractor.py`: Fixed `isinstance(obj, WireType)` checks + in `_build_domain_kwargs()` — replaced with `as_type` string comparisons + (`_obj_type == str(VOtype.X)`) because the wire parser deserializes nested + objects as `as_Object` base class, causing `isinstance` checks against + Vultron subtypes to always fail. +- `vultron/core/use_cases/status.py`: Fixed `add_case_status_to_case` and + `add_participant_status_to_participant` to fall back to `event.status` when + `dl.read(status_id)` returns a non-model (raw TinyDB Document). +- `vultron/core/models/vultron_types.py`: Added `field_serializer` for + `pxa_state` on `VultronCaseStatus` and `vfd_state` on + `VultronParticipantStatus` to serialize as `.name` strings (matching wire + type serialization), fixing `[0,0,0]` round-trip failures. +- Test files: Updated for `DispatchEvent` rename. +- `specs/dispatch-routing.md`, `specs/handler-protocol.md`, + `specs/code-style.md`, `specs/architecture.md`, `specs/README.md`, + `AGENTS.md`, `docs/adr/0009-hexagonal-architecture.md`, + `docs/reference/inbox_handler.md`: Updated `DispatchActivity` → `DispatchEvent`. + +**Result**: 888 tests pass, 0 regressions. + +**Next**: P75-2c — model dispatcher as formal driving port. + +## TECHDEBT-14 — Split vultron_types.py into per-type modules + +**Completed**: 2026-03-13 + +Split `vultron/core/models/vultron_types.py` (273 lines, 11 classes) into +individual per-type modules following the `wire/as2/vocab/objects/` pattern: + +- `_helpers.py` — shared `_now_utc` / `_new_urn` factory functions +- `case_event.py` — `VultronCaseEvent` +- `case_status.py` — `VultronCaseStatus` +- `participant_status.py` — `VultronParticipantStatus` +- `participant.py` — `VultronParticipant` +- `case_actor.py` — `VultronCaseActor`, `VultronOutbox` +- `activity.py` — `VultronActivity`, `VultronOffer`, `VultronAccept`, `VultronCreateCaseActivity` +- `report.py` — `VultronReport` +- `case.py` — `VultronCase` +- `note.py` — `VultronNote` +- `embargo_event.py` — `VultronEmbargoEvent` + +`vultron_types.py` retained as a backward-compatibility re-export shim. +All existing callers continue to work without modification. 887 tests pass. + +Note: `test_remove_embargo` is a pre-existing flaky test (py_trees blackboard +global state) that passes in isolation but occasionally fails in full suite. diff --git a/plan/IMPLEMENTATION_NOTES.md b/plan/IMPLEMENTATION_NOTES.md index 76af09f3..c288fcd1 100644 --- a/plan/IMPLEMENTATION_NOTES.md +++ b/plan/IMPLEMENTATION_NOTES.md @@ -1,4 +1,4 @@ -# Implementation Notes +## Implementation Notes Longer-term notes can be found in `/notes/*.md`. This file is ephemeral and will be reset periodically, so it's meant to capture more immediate @@ -8,113 +8,238 @@ Add new items below this line --- -## 2026-03-09 — Architecture violation inventory now formal - -`specs/architecture.md` and `notes/architecture-review.md` were added since the -last plan refresh. The review identifies 11 violations (V-01 to V-11) and a -remediation plan (R-01 to R-06). The most impactful violations are: - -- **V-01**: `MessageSemantics` mixed with AS2 structural enums in `vultron/enums.py` -- **V-02**: `DispatchActivity.payload: as_Activity` (AS2 type leaks into core) -- **V-04**: `verify_semantics` decorator re-invokes `find_matching_semantics` - (second AS2-to-domain mapping point, violates Rule 4) - -Phase ARCH-1 now tracks this work. ARCH-1.1 (R-01) must be done before ARCH-1.2 -(R-02), which must precede ARCH-1.3 (R-03/R-04). - -## 2026-03-09 — P30-4 `close-report` vs `reject-report` distinction - -Both `reject-report` and `close-report` emit `RmCloseReport` (`as_Reject`), but -they differ in context: - -- `reject-report` hard-rejects an incoming report offer (offer not yet validated; - `object=offer.as_id`) -- `close-report` closes a report after the RM lifecycle has proceeded (RM → C - transition; emits RC message) - -The existing `trigger_reject_report` implementation uses `offer_id` as its target. -The `trigger_close_report` implementation should also use `offer_id` but should -validate that the offer's report is in an appropriate RM state for closure (not -just any offered report). This distinction should be documented in the endpoint -docstring. - -## 2026-03-09 — CS-09-002 duplication in triggers.py request models - -`ValidateReportRequest` and `InvalidateReportRequest` in `triggers.py` are -structurally identical (both have `offer_id: str` and `note: str | None`). Per -CS-09-002, these should be consolidated into a single base model with the other -as a subclass or alias. Low-priority but worth addressing when the file is next -modified. - ---- - -## 2026-03-09 — P30-4 complete: close-report trigger endpoint - -`POST /actors/{actor_id}/trigger/close-report` added. Emits `RmCloseReport` -(RM → C transition), updates offer/report status and actor outbox, returns -HTTP 409 if already CLOSED. 9 unit tests added. - -Also converted `RM` from plain `Enum` to `StrEnum` for consistency with `EM`. -This changes `str(RM.X)` from `"RM.REPORT_MANAGEMENT_X"` to `"X"`, resulting -in cleaner BT node names (e.g., `q_rm_in_CLOSED` instead of -`q_rm_in_RM.REPORT_MANAGEMENT_CLOSED`). Updated `test_conditions.py` to check -`state.value` in node name instead of `state.name`. +### 2026-03-11 — Refresh #24 findings + +**P65 fully complete**: All tasks P65-1 through P65-7 are confirmed complete by +code inspection. V-01 through V-23 are all resolved. See `IMPLEMENTATION_HISTORY.md` +for details. + +**`notes/architecture-review.md` is stale**: The status block and per-violation +markers still show V-03-R, V-15–19, and V-22–23 as open or partial. These were +resolved by P65-4, P65-6b, and P65-7 respectively. ARCH-DOCS-1 task added. + +**New violation V-24**: `vultron/wire/as2/vocab/examples/_base.py` imports +`DataLayer` from `vultron.api.v2.datalayer.abc` at module scope, and also +imports `Record` and `get_datalayer` from `api/v2/datalayer/` inside +`initialize_examples()`. This makes the wire layer dependent on the adapter +layer — a Rule 1 violation. Captured in TECHDEBT-13b. + +**Residual V-23**: `test/core/behaviors/report/test_policy.py` still imports +`VulnerabilityReport` from `vultron.wire.as2.vocab.objects.vulnerability_report`. +Tests pass (duck-typing), but the import violates the rule that core tests must +not use wire-layer types as fixtures. Captured in TECHDEBT-13a. + +**TYPE_CHECKING imports in top-level modules**: `vultron/types.py` and +`vultron/behavior_dispatcher.py` both have `TYPE_CHECKING` guards importing +`DataLayer` from `vultron.api.v2.datalayer.abc` (the backward-compat shim). +These should import from `vultron.core.ports.activity_store` directly. +Captured in TECHDEBT-13c. + +**`api/v2` → `core/use_cases/` migration (PRIORITY-75)**: The 38 handlers +(2223 lines) in `api/v2/backend/handlers/` and the trigger services (1188 lines) +in `api/v2/backend/trigger_services/` contain domain logic that belongs in +`core/use_cases/`. The `core/use_cases/__init__.py` stub docstring already +describes the intent: "Incoming port: domain use-case callables." Migration +requires domain event types (`VultronEvent`) first (P75-1), then handler +extraction (P75-2), then trigger service extraction (P75-3), then updating +driving adapter stubs to call use cases directly (P75-4). + +**api/v1 is already architecturally compliant**: All v1 routers return +`vocab_examples.*` results with no business logic. They are already thin HTTP +adapters over `wire/as2/vocab/examples/`. The only decision needed is whether +to keep, merge, or deprecate (P75-5). + +**P70 needs P70-4 and P70-5**: Moving TinyDB from `api/v2/datalayer/tinydb.py` +to `adapters/driven/activity_store.py` (P70-4) and removing the backward-compat +shims (P70-5) were missing from the plan. Added in refresh #24. + +**`vultron_types.py` split**: `vultron/core/models/vultron_types.py` bundles +11 domain classes in 273 lines. `notes/codebase-structure.md` recommends +splitting into per-type modules (like `wire/as2/vocab/objects/`). Low-priority +organizational improvement captured as TECHDEBT-14. + +**`use_cases` directory**: `vultron/core/use_cases/__init__.py` exists with a +stub docstring but contains no implementations. Driving adapter stubs +(`http_inbox.py`, `mcp_server.py`) reference `core/use_cases/` as the +future home for use-case callables. No actionable task yet — this will come +with the hexagonal architecture maturing (PRIORITY 70+). + +## Renamed activity_store + +`core/ports/activity_store.py` was renamed to `core/ports/datalayer.py` to +reflect the broader scope of the port. + +`adapters/driven/activity_store.py` was renamed to +`adapters/driven/datalayer-tinydb.py` to reflect the specific +implementation and avoid confusion with the port. (Eventually when we get to +having a mongo-db implementation we will want to make +`adapters/driven/datalayer` into a package with `tinydb.py` and `mongodb.py` +as modules.) + +## docker README out of date + +`docker/README.md` is out of date and needs to be updated to reflect the +currently available services and how to run them. + +## Previous refactors broke documentation generation + +There are python inline code blocks in `docs/` that broke when the +`as_vocab` modules got moved into `wire/as2/vocab/`. +These need to be updated to reflect the new paths. By building the site +using `mkdocs build` to detect errors. + +## Core models must not be less rich than wire models + +Because we have been in the process of separating core models from the wire +models (they're semantically identical but we need to maintain the +separation to maintain architectural integrity), we're frequently running +into situations where the core model is less rich than the wire model, and +so we're piecemeal adding features to core models to reflect things we left +out of the translation from wire to core models. This is a code smell that +suggests we need to invert our thinking here: the core models need to be the +rich, fully featured models that capture all the domain semantics, and the +wire models should ensure translation from/to syntax to/from the core models. +This is a recurring problem in recent implementation steps, and it would be +better if we resolve it once by enriching the core models, then refactoring +the wire models to provide the necessary syntactic translation, rather than +continuing to add individual features to the core models as we encounter +them. This should be captured as a technical debt item to be resolved as +soon as possible as it will head off a lot of future code-level challenges. + +## Important notes after P75-2 and bfore P75-3 + +`vultron/api/v2/backend/handlers` is no longer serving much of a purpose. +The expectation here is that `vultron.api.v2.backend.inbox_handler. +inbox_handler()` would lookup the use case function to call in the +dispatcher and call use case objects directly. Right now +So for example, on receipt of an `AddNoteToCaseActivity`, the inbox handler +would know to create an `AddNoteToCaseReceivedEvent`. It seems like this +hinges on `prepare_for_dispatch()`, and the DispatchActivity class. Ideally, +instead of passing wire activity and wire object into the dispatch message, +the DispatchActivity would be constructing a core event object. Consider +renaming DispatchActivity to DispatchEvent to make it clear that it carries +a core event model, not a wire activity model. We should be careful in our +naming to be consistent that "Activity" is a wire-level concept and "Event" +is a core-level concept. (There is some tension with "EmbargoEvent" being an +object based on an "Event" object in the AS2 vocabulary), but we just need +to be careful around that naming where it matters. + +This may require changes to `extract_intent()` or at how it is used in +`prepare_for_dispatch()`. Part of the problem here seems to be that we +created the dispatcher before we really understood the wire vs domain model +separation, so it seems as if there is some extra complexity in the dispatch +message construction because of this. With the previous note about core +models needing to be the rich models, it seems like there is a more direct +way for DispatchActivity objects to be constructed with core object events +not really needing the wire objects at all (this means that we really need +to ensure that the core models capture the full content of what's coming in +from the wire models, even if we're not yet using all the features in the +wire models yet. Consider: any activity or object can have a "content" field +that we might not show being used in our examples yet, but we need to make +sure it gets passed through from wire to core.) + + +Implications: +- the pattern objects in `extractor.py` should be suffixed with +Pattern to clarify their purpose and distinguish them from similarly named +Activity and Event objects. --- -## Triggerable behaviors should start to live in `vultron/core/` and respect the architecture - -The `triggers.py` module currently has a mix of architectural violations, including -including directly invoking domain logic in the routers. We should start -separating these concerns as soon as practical in preparation for the -architecture refactor. It would be better to start moving towards the -cleaner architecture where we can now than to continue building out more -things that will have to be refactored later. We know the direction we are -going with the architecture, so we should start moving in that direction now -when we can. - -This idea generalizes too. When you're modifying or adding new router code, -consider the architectural intent and whether the code you're writing -respects the intended separation of concerns. Try to avoid mixing domain -logic directly into the routers, and instead think about how to structure the -code so that the ports and adapters model is cleaner even before the full -refactor. - -Fix what you can as you go, and add items you observe as technical debt to -the implementation notes for anything you notice but can't fix immediately. - -Technical debt: Refactor triggers.py to respect the hexagonal architecture -concepts. - -## Consider use of `transitions` module for state machines - -Although we have manually enumerated state machine states for EM, RM, and CS, -we don't really have a clean implementation of the state machines themselves. -We should consider integrating the `transitions` module to make it easier to -define and maintain the state machines. This is not a high priority right -now, but if we find an opportunity to integrate it cleanly (especially if it -would help solve a problem down the road) we should consider doing so. - - - ---- - -## 2026-03-09 — P30-6 complete: trigger sub-command in vultron-demo CLI - -Added `vultron-demo trigger` sub-command backed by `vultron/demo/trigger_demo.py`. +## 2026-03-13 — Dispatch/Emit architecture clarification (refresh #31) + +### Dispatch vs Emit terminology + +Two distinct port concepts exist for activity flow: + +- **Dispatch** = inbound: wire activity received → core use case invoked. + This is a **driving port** — the core *exposes* an interface that adapters + (HTTP inbox, CLI, MCP) call into. The `ActivityDispatcher` Protocol should + live in `core/ports/dispatcher.py` alongside `DataLayer`. + +- **Emit** = outbound: core action → wire object sent to recipient(s). + This is a **driven port** — the core calls *out* to an external system that + delivers the activity. A future `ActivityEmitter` Protocol belongs in + `core/ports/emitter.py` (or similar). The delivery-queue adapter would + implement it. This is distinct from, but complementary to, the + `delivery_queue.py` port already sketched — the emitter port is the + use-case-facing interface; delivery queue is the transport implementation. + +Keep these terms consistent throughout code, comments, specs, and docs: +"dispatch" for inbound routing into use cases, "emit" for outbound sending. + +### Post-P75-2 architecture findings (context for P75-2a/b/c) + +**DispatchActivity carries wire objects**: `DispatchActivity` in `vultron/types.py` +has `wire_activity: Any` and `wire_object: Any` fields. These leak wire types into +the dispatch envelope and into use case signatures. Use cases in +`core/use_cases/` still accept `wire_object=None` kwargs. This is resolved by +P75-2a (enrich domain models) + P75-2b (remove wire fields). + +**DispatchActivity → DispatchEvent rename**: "Activity" is a wire concept; +the dispatch envelope carries a `VultronEvent` domain payload. Rename to +`DispatchEvent` in P75-2b. Be careful around `EmbargoEvent` (an AS2 object +type, not a `VultronEvent` subclass) — the naming is coincidental and should +be clearly distinguished in documentation. + +**Handler layer is vestigial after P75-2**: The 2–3-line delegate functions in +`vultron/api/v2/backend/handlers/` are the only remaining purpose of that +layer (plus `@verify_semantics`). Eliminate in P75-2c by mapping +`SEMANTICS_HANDLERS` directly to use case callables. + +**SEMANTICS_HANDLERS belongs in core**: The routing table maps domain concepts +(`MessageSemantics`) to domain callables (`core/use_cases/`). It is domain +knowledge, not adapter configuration. Move to `core/use_cases/use_case_map.py` +as part of P75-2c. + +**ActivityDispatcher as driving port**: Move Protocol from `vultron/types.py` +to `core/ports/dispatcher.py`. This makes the inbound dispatch interface +explicit and injectable (for testing). Concrete implementation moves to core +(or a driving adapter); the inbox handler injects it rather than using the +module-level singleton. + +**Pattern naming inconsistency**: `ActivityPattern` instances in +`SEMANTICS_ACTIVITY_PATTERNS` in `extractor.py` have names like `CreateReport`, +`EngageCase` — identical to Activity and Event class names. Add `Pattern` suffix +(`CreateReportPattern`, etc.) in P75-2c. + +## Separation of responsibilities + +Core receives semantically meaningful events via use case callables, which +are exposed via driving ports. Driving adapters are responsible for +translating their specific input syntax into core domain events and calling +the appropriate use case. Core processes the event, performs domain logic, and +emits semantically meaningful events via the emitter port. Driven adapters are +responsible for translating the emitted domain events into the appropriate output +syntax and delivering them to the appropriate recipients. + +## DRY up vultron.core.models.vultron_types and vultron.core.models.events + +There is some redundancy between the domain models in `vultron.core.models.vultron_types` +and the domain event models in `vultron.core.models.events`. This really +should be a single class hierarchy that captures common fields in a +`VultronObject` base class that `VultronEvent` can inherit from, and the +other models in `vultron_types` can also inherit from `VultronObject`. In +general, we shouldn't have a lot of direct inheritance from `pydantic. +BaseModel` in our domain models, instead we should have our own base class +or classes that inherit from `BaseModel` and capture all the common fields +so that our domain models can inherit from those and just have their +specific details and fields where needed. This is very much parallel to the +class hierarchy in the wire layer where there is an `as_Base` -> `as_Object` +-> `as_Activity` etc. hierarchy. This parallel is deliberate, as the Vultron +object and event models are meant to be rich domain data models that can be +expressed in the wire layer with the appropriate syntactic translation. + +## Flaky test is technical debt + +The test `test/wire/as2/vocab/test_vocab_examples. +py::TestVocabExamples::test_remove_embargo` has been identified as flaky. +This is a technical debt item that should be resolved as soon as possible as +we must not have flaky tests in our suite. The test should be inspected to +determine the root cause of the flakiness, and refactored to be reliable. +Add new TECHDEBT item to capture this, prioritize its resolution accordingly, +and add an item in `specs/testability.md` requiring that all tests must be +reliable and consistent. -Two end-to-end demo workflows are implemented: -- **Demo 1 (validate and engage)**: finder submits report via inbox → vendor - calls `POST .../trigger/validate-report` → vendor calls - `POST .../trigger/engage-case`. -- **Demo 2 (invalidate and close)**: finder submits report via inbox → vendor - calls `POST .../trigger/invalidate-report` → vendor calls - `POST .../trigger/close-report`. -Supporting changes: -- Added `post_to_trigger()` helper to `vultron/demo/utils.py`. -- Added `trigger` demo to `DEMOS` list in `vultron/demo/cli.py`; it now runs - as part of `vultron-demo all`. -- Updated `docs/reference/code/demo/demos.md` and `cli.md` with new entries. -Phase PRIORITY-30 is now fully complete (P30-1 through P30-6). diff --git a/plan/IMPLEMENTATION_PLAN.md b/plan/IMPLEMENTATION_PLAN.md index c7d309ca..367ba2a4 100644 --- a/plan/IMPLEMENTATION_PLAN.md +++ b/plan/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # Vultron API v2 Implementation Plan -**Last Updated**: 2026-03-09 (gap analysis refresh #19, P30-6 complete) +**Last Updated**: 2026-03-13 (refresh #33: P75-2b complete) ## Overview @@ -9,91 +9,124 @@ Completed phase history is in `plan/IMPLEMENTATION_HISTORY.md`. ### Current Status Summary -**Test suite**: 736 passing, 5581 subtests, 0 xfailed (2026-03-06) - -**All 38 handlers implemented** (including `unknown`): -create_report, submit_report, validate_report (BT), invalidate_report, ack_report, -close_report, engage_case (BT), defer_case (BT), create_case (BT), -add_report_to_case, close_case, create_case_participant, -add_case_participant_to_case, invite_actor_to_case, -accept_invite_actor_to_case, reject_invite_actor_to_case, -remove_case_participant_from_case, create_embargo_event, -add_embargo_event_to_case, remove_embargo_event_from_case, -announce_embargo_event_to_case, invite_to_embargo_on_case, -accept_invite_to_embargo_on_case, reject_invite_to_embargo_on_case, -create_note, add_note_to_case, remove_note_from_case, create_case_status, -add_case_status_to_case, create_participant_status, -add_participant_status_to_participant, suggest_actor_to_case, -accept_suggest_actor_to_case, reject_suggest_actor_to_case, -offer_case_ownership_transfer, accept_case_ownership_transfer, -reject_case_ownership_transfer, update_case - -**Trigger endpoints** (P30-1 through P30-3 complete): -`validate-report`, `invalidate-report`, `reject-report`, `engage-case`, `defer-case` - -**Demo scripts** (all dockerized in `docker-compose.yml`): -`receive_report_demo.py`, `initialize_case_demo.py`, `invite_actor_demo.py`, -`establish_embargo_demo.py`, `status_updates_demo.py`, `suggest_actor_demo.py`, -`transfer_ownership_demo.py`, `acknowledge_demo.py`, `manage_case_demo.py`, -`initialize_participant_demo.py`, `manage_embargo_demo.py`, -`manage_participants_demo.py` +**Test suite**: 880 passing, 5581 subtests, 0 xfailed (2026-03-11, after P70-2) + +**All 38 handlers implemented** (including `unknown`) — see `IMPLEMENTATION_HISTORY.md`. +**Trigger endpoints**: all 9 complete (P30-1–P30-6). **Demo scripts**: 12 scripts, +all dockerized in `docker-compose.yml`. --- -## Gap Analysis (2026-03-09, refresh #19) +## Gap Analysis (2026-03-11, refresh #24) ### ✅ Previously completed (see `plan/IMPLEMENTATION_HISTORY.md`) BUGFIX-1, REFACTOR-1, DEMO-3, DEMO-4, SPEC-COMPLIANCE-1, SPEC-COMPLIANCE-2, -SC-3.1, SC-PRE-1, TECHDEBT-1, TECHDEBT-5, TECHDEBT-6, P30-1, P30-2, P30-3. +SC-3.1, SC-PRE-1, TECHDEBT-1, TECHDEBT-5, TECHDEBT-6, TECHDEBT-10, TECHDEBT-11, P30-1, +P30-2, P30-3, P30-4, P30-5, P30-6, P50-0, ARCH-1.1, ARCH-1.2, ARCH-1.3, +ARCH-1.4, ARCH-CLEANUP-1, ARCH-CLEANUP-2, ARCH-CLEANUP-3, ARCH-ADR-9, P60-1, +P60-2, P60-3, TECHDEBT-3, TECHDEBT-7, TECHDEBT-8, TECHDEBT-9, TECHDEBT-10, +TECHDEBT-11, TECHDEBT-12, SC-PRE-2, SC-3.2, SC-3.3, +P65-1, P65-2, P65-3, P65-4, P65-5, P65-6a, P65-6b, P65-7. ### ❌ Outbox delivery not implemented (lower priority) `actor_io.py` stub logs placeholder messages (OX-03-001, OX-04-001, OX-04-002). -### ⚠️ Triggerable behaviors partially implemented (PRIORITY 30) +### ✅ Triggerable behaviors fully implemented (PRIORITY 30 — COMPLETE) + +All 9 trigger endpoints in split router files. P30-1 through P30-6 complete. + +### ✅ Hexagonal architecture violations remediated (PRIORITY 65 — ALL COMPLETE) + +All P65 tasks (P65-1 through P65-7) are complete. All violations V-01 through +V-23 are resolved: + +- **V-03-R ✅ (P65-4)**: `behavior_dispatcher.py` has no runtime wire imports; + `extract_intent()` moved to adapter layer in `inbox_handler.py`. +- **V-15/16/17/18/19 ✅ (P65-6b)**: Core BT nodes (`report/nodes.py`, + `case/nodes.py`, `report/policy.py`, `case/create_tree.py`) use domain types + from `vultron.core.models.vultron_types`; no wire-layer AS2 imports remain. +- **V-22/23 ✅ (P65-7)**: All core BT test files use domain type fixtures; + `test_behavior_dispatcher.py` no longer imports wire types. + +**⚠️ Stale docs**: `notes/architecture-review.md` still shows V-03-R, V-15–19, +V-22–23 as open/partial. ARCH-DOCS-1 task added to update these markers. + +**Residual**: `test/core/behaviors/report/test_policy.py` still imports +`VulnerabilityReport` from `vultron.wire.as2.vocab.objects.vulnerability_report` +(policy tests pass via duck-typing). Captured in TECHDEBT-13. -P30-1 through P30-3 complete (5 endpoints in `vultron/api/v2/routers/triggers.py`). -Remaining: P30-4 (`close-report`), P30-5 (EM triggers), P30-6 (demo CLI). +### ✅ Package relocation Phase 1 complete (PRIORITY 60 — COMPLETE) -### ❌ Hexagonal architecture shift not started (PRIORITY 150) +- `vultron/as_vocab/` → `vultron/wire/as2/vocab/` (P60-1 ✅) +- `vultron/behaviors/` → `vultron/core/behaviors/` (P60-2 ✅) +- `vultron/adapters/` package stub created (P60-3 ✅) -`specs/architecture.md` (ARCH-01 through ARCH-08) is now formal. `notes/architecture-review.md` -documents 11 violations (V-01 to V-11) with remediation plan (R-01 to R-06). No -architecture refactoring tasks are yet tracked in the implementation plan. +### ❌ DataLayer shims removed (PRIORITY 70 — Phase 1 COMPLETE ✅) + +`vultron/api/v2/datalayer/abc.py`, `tinydb_backend.py`, and `db_record.py` have been +removed. All callers now import `DataLayer` from `vultron.core.ports.datalayer`, +`TinyDbDataLayer`/`get_datalayer`/`reset_datalayer` from +`vultron.adapters.driven.datalayer_tinydb`, and `Record`/`object_to_record` from +`vultron.adapters.driven.db_record`. P70-2 through P70-5 complete. + +### ❌ Handlers and trigger services not yet extracted to core/use_cases/ (PRIORITY 75) + +`vultron/api/v2/backend/handlers/` (2223 lines, 38 handlers) and +`vultron/api/v2/backend/trigger_services/` (1188 lines) contain domain +business logic that belongs in `vultron/core/use_cases/`. The +`vultron/core/use_cases/__init__.py` stub exists but is empty. Once extracted, +handlers and trigger-service functions become thin driving-adapter delegates +that call into core use cases. This also enables `adapters/driving/cli.py` +and `adapters/driving/mcp_server.py` to call the same use cases without going +through HTTP. See Phase PRIORITY-75. + +### ❌ api/v1 disposition not planned + +`vultron/api/v1/` is a vocabulary-examples HTTP adapter (returns `vocab_examples.*` +results; no real business logic). It is already architecturally compliant — +thin routers over `wire/as2/vocab/examples/`. No migration is needed. +Decision required: keep as-is, formally deprecate, or remove. Captured as P75-5. ### ❌ Actor independence not implemented (PRIORITY 100) All actors share a singleton `TinyDbDataLayer` instance. PRIORITY 100 requires per-actor isolated state. Options documented in `notes/domain-model-separation.md` (Option B: TinyDB namespace prefix; MongoDB community edition for production). +Blocked by PRIORITY-70 (DataLayer relocation). ### ❌ CaseActor broadcast not implemented (PRIORITY 200) CM-06-001 requires CaseActor to notify all case participants on case state update. Blocked by OUTBOX-1. -### ⚠️ SPEC-COMPLIANCE-3 partially done (SC-PRE-2, SC-3.2, SC-3.3 remain) +### ✅ SPEC-COMPLIANCE-3 complete (SC-PRE-2, SC-3.2, SC-3.3 all done) -`add_participant()` on `VulnerabilityCase` exists but does not maintain an -`actor_participant_index` dict (SC-PRE-2 incomplete). No handler records embargo -acceptances with trusted timestamps (SC-3.2). No `update_case` guard checks -participant embargo acceptance (SC-3.3). +`SC-PRE-2`, `SC-3.2`, and `SC-3.3` are all complete. The `update_case` guard +checks participant embargo acceptance and logs a WARNING (CM-10-004); full +enforcement deferred to PRIORITY-200. -### ❌ CS-08-001 — Optional string fields allow empty strings (TECHDEBT-7/9) +### ❌ `vultron/enums.py` backward-compat shim still present (TECHDEBT-4 / P70-2) -No Pydantic validators enforce "if present, then non-empty" on `Optional[str]` -fields across `vultron/as_vocab/objects/` models. +`vultron/enums.py` remains as a backward-compat re-export shim for `MessageSemantics` +plus defines `OfferStatusEnum` and `VultronObjectType`. These two domain-boundary +enums should eventually be relocated (`OfferStatusEnum` → `core/models/`, +`VultronObjectType` → `wire/as2/enums.py` or `core/models/`). `vultron/enums.py` +can then be deleted. Depends on completing PRIORITY-70. See P70-2. -### ❌ Pyright static type checking not configured (TECHDEBT-8) +### ❌ `vultron/core/ports/` missing delivery_queue and dns_resolver stubs (P70-3) -No `pyrightconfig.json` exists. `specs/tech-stack.md` IMPL-TS-07-002 requires -pyright adoption with a gradual approach. +`vultron/adapters/driven/delivery_queue.py` and `dns_resolver.py` reference +`core/ports/delivery_queue.py` and `core/ports/dns_resolver.py` as their port +interfaces, but those Protocol stub files do not exist yet. P70-3 must add them. -### ❌ Object IDs not standardized to URL-like form (TECHDEBT-3) +### ❌ New violation V-24: `wire/as2/vocab/examples/_base.py` imports from adapter layer -No ADR for `docs/adr/ADR-XXXX-standardize-object-ids.md`. `specs/object-ids.md` -OID-01 through OID-04 defines requirements. +`vultron/wire/as2/vocab/examples/_base.py` imports `DataLayer` from +`vultron.api.v2.datalayer.abc` at module level and `Record`, `get_datalayer` from +`api/v2/datalayer/` inside `initialize_examples()`. Wire layer must not import +from the adapter layer. Captured in TECHDEBT-13. ### ❌ Multi-actor demos not yet started (PRIORITY 300) @@ -103,84 +136,87 @@ Blocked by PRIORITY-100 and PRIORITY-200. ## Prioritized Task List -### Phase PRIORITY-30 — Triggerable Behaviors (PRIORITY 30) +### Phase PRIORITY-30 — Triggerable Behaviors (COMPLETE ✅) + +All P30 tasks (P30-1 through P30-6) complete. All 9 trigger endpoints implemented. +See `plan/IMPLEMENTATION_HISTORY.md` for details. + +--- -**Reference**: `specs/triggerable-behaviors.md`, `notes/triggerable-behaviors.md` +### Phase PRIORITY-50/60/65 — Hexagonal Architecture (ALL COMPLETE ✅) -- [x] **P30-1** through **P30-3**: Complete — see `plan/IMPLEMENTATION_HISTORY.md` +P50-0, ARCH-1.1–1.4, ARCH-CLEANUP-1/2/3, P60-1/2/3, P65-1–7 all complete. +V-01 through V-23 resolved. See `plan/IMPLEMENTATION_HISTORY.md` for details. -- [x] **P30-4**: Add `POST /actors/{actor_id}/trigger/close-report` endpoint - (TB-02-001) with `offer_id` in request body (TB-03-001). Emits `RmCloseReport` - (RM → C transition / RC message), updates RM state to CLOSED, adds to outbox - (TB-07-001), returns `{"activity": {...}}` (TB-04-001). Note: `close-report` - closes a report in the RM lifecycle (RM → C); `reject-report` hard-rejects an - offer pre-validation. Add unit + integration tests (happy path, missing `offer_id` - 422, unknown actor 404, outbox update). +--- -- [x] **P30-5**: Add EM trigger endpoints: `propose-embargo`, `evaluate-embargo`, - `terminate-embargo` (TB-02-002). All require `case_id` in request body - (TB-03-001); `propose-embargo` SHOULD also accept optional `note` and embargo - duration. Each emits the corresponding EM ActivityStreams activity, updates - `CaseStatus.em_state`, adds to outbox, returns HTTP 202 with - `{"activity": {...}}`. Add tests for each endpoint (happy path, missing `case_id` - 422, unknown actor 404). +### Phase SPEC-COMPLIANCE-3 — Embargo Acceptance Tracking (COMPLETE ✅) -- [x] **P30-6**: Add a `trigger` sub-command to the `vultron-demo` CLI exercising - at least one trigger endpoint end-to-end. Update `docs/reference/code/demo/*.md`. +SC-PRE-2, SC-3.2, SC-3.3 all complete. See `plan/IMPLEMENTATION_HISTORY.md`. --- -### Phase SPEC-COMPLIANCE-3 — Embargo Acceptance Tracking + Trusted Timestamps +### Technical Debt (housekeeping) — all complete ✅ -**Reference**: `specs/case-management.md` CM-10, CM-02-009 +TECHDEBT-3, TECHDEBT-7, TECHDEBT-8, TECHDEBT-9, TECHDEBT-10, TECHDEBT-11, +TECHDEBT-12 all done. TECHDEBT-4 superseded by P70-2. +See `plan/IMPLEMENTATION_HISTORY.md`. + +--- -- [ ] **SC-PRE-2**: Add `actor_participant_index: dict[str, str]` field to - `VulnerabilityCase`; update `add_participant()` and add `remove_participant()` - to maintain the index atomically (CM-10-002). Update all handlers that create or - remove participants to use these methods. Add tests confirming index consistency. +### ARCH-DOCS-1 — Update architecture-review.md violation status markers -- [ ] **SC-3.2**: In `accept_invite_to_embargo_on_case` and - `accept_invite_actor_to_case` handlers, record the accepted embargo ID in - `CaseParticipant.accepted_embargo_ids` using the CaseActor's trusted timestamp - via `VulnerabilityCase.record_event()` (CM-10-002, CM-02-009). Add tests. +**Priority**: High (docs correctness) -- [ ] **SC-3.3**: Add a guard in `update_case` (or a shared helper) that checks - each active participant has accepted the current embargo before broadcasting - (CM-10-004). For prototype: log WARNING when a participant has not accepted; - full enforcement is PRIORITY-200. Add unit tests. +- [x] **ARCH-DOCS-1**: Update `notes/architecture-review.md` to mark V-03-R + (P65-4), V-15/16/17/18/19 (P65-6b), and V-22/23 (P65-7) as fully resolved. + Update the status header block at the top of the file to reflect the current + state: all violations V-01 through V-23 resolved. Done when the file + accurately reflects the post-P65-7 state and no violation is misrepresented + as open or partial. --- -### Technical Debt (housekeeping) +### TECHDEBT-13 — Minor wire-boundary cleanup items + +**Priority**: Medium (architecture hygiene) + +- [x] **TECHDEBT-13a**: Update `test/core/behaviors/report/test_policy.py` to + replace the `VulnerabilityReport` import from `vultron.wire.as2.vocab.objects` + with `VultronReport` from `vultron.core.models.vultron_types`. Done when no + core test imports wire-layer AS2 vocabulary types and tests pass. (Residual + V-23 cleanup.) +- [x] **TECHDEBT-13b**: Fix V-24 — update `vultron/wire/as2/vocab/examples/_base.py` + to eliminate adapter-layer imports. The `DataLayer` annotation should use the + core port (`vultron.core.ports.activity_store.DataLayer`); the `initialize_examples()` + function should accept a `DataLayer` argument only (removing the `get_datalayer()` + fallback and `Record` import). Done when `_base.py` has no imports from + `vultron.api.v2.datalayer.*` and tests pass. +- [x] **TECHDEBT-13c**: Update `TYPE_CHECKING` imports in `vultron/types.py` and + `vultron/behavior_dispatcher.py` to reference `vultron.core.ports.activity_store.DataLayer` + directly instead of `vultron.api.v2.datalayer.abc.DataLayer` (the shim). + Done when no `core/` or top-level module imports from `api/v2/datalayer/abc` + at type-check time. -- [ ] **TECHDEBT-9**: Introduce `NonEmptyString` and `OptionalNonEmptyString` type - aliases in `vultron/as_vocab/base/` (CS-08-001, CS-08-002). Replace existing - per-field empty-string validators with the shared type. **Combine with - TECHDEBT-7** in one agent cycle. +--- + +### TECHDEBT-14 — Split `vultron/core/models/vultron_types.py` into per-type modules -- [ ] **TECHDEBT-7**: Add Pydantic validators rejecting empty strings in all - remaining `Optional[str]` fields across `vultron/as_vocab/objects/` models - (CS-08-001). Done when all fields reject empty strings and tests pass. +**Priority**: Low (organizational) -- [ ] **TECHDEBT-10**: Backfill pre-case events into the case event log at case - creation (CM-02-009). `create_case` BT SHOULD call `record_event()` for the - originating Offer receipt and case creation events. Add tests. +- [x] **TECHDEBT-14**: Split `vultron/core/models/vultron_types.py` (273 lines, + 11 classes) into individual modules following the `wire/as2/vocab/objects/` + pattern (e.g., `core/models/report.py`, `core/models/case.py`). Add a + re-export shim at `vultron/core/models/vultron_types.py` for backward compat + (similar to `api/v2/datalayer/abc.py`). Done when each type has its own + module, re-exports work for existing callers, and tests pass. -- [ ] **TECHDEBT-8**: Configure pyright for gradual static type checking - (IMPL-TS-07-002). Commit `pyrightconfig.json` at `basic` strictness; run - pyright to produce a baseline error count documented in - `plan/IMPLEMENTATION_NOTES.md`; add a `Makefile` target. Done when config - committed and baseline documented. +--- -- [ ] **TECHDEBT-3**: Standardize object IDs to URL-like form — draft ADR - `docs/adr/ADR-XXXX-standardize-object-ids.md` and implement a compatibility - shim in the DataLayer (OID-01 through OID-04). Done when ADR created and - tests validate URL-like ID acceptance. +### Phase PRIORITY-65 — Address Architecture Violations (ALL COMPLETE ✅) -- [ ] **TECHDEBT-4**: Reorganize top-level modules (`activity_patterns`, - `semantic_map`, `enums`) into small packages to reduce circular imports and - improve discoverability. Done when modules moved with minimal interface - changes and tests pass. +All P65 tasks (P65-1 through P65-7) complete. All violations V-01 through V-23 +resolved. See `plan/IMPLEMENTATION_HISTORY.md` for full task details. --- @@ -207,37 +243,175 @@ Blocked by PRIORITY-100 and PRIORITY-200. --- -### Phase ARCH-1 — Hexagonal Architecture Remediation (PRIORITY 150) +### Phase PRIORITY-70 — DataLayer Refactor into Ports and Adapters + +**Reference**: `plan/PRIORITIES.md` PRIORITY 70, +`notes/domain-model-separation.md` (Per-Actor DataLayer Isolation Options), +`notes/architecture-ports-and-adapters.md` + +**P70-1 SUPERSEDED by P65-1** — DataLayer Protocol move to `core/ports/` done. +**Must precede**: PRIORITY-100 (actor independence uses the new layer structure). + +- [x] **P70-2**: Move `OfferStatusEnum` and `VultronObjectType` from + `vultron/enums.py` to their correct architectural homes (`core/models/` and + `wire/as2/enums.py` respectively). Delete `vultron/enums.py`. Update all + callers (about 13 files import from `vultron.enums`). Done when no + `vultron.enums` imports remain and tests pass. + +- [x] **P70-3**: Add `vultron/core/ports/delivery_queue.py` and + `vultron/core/ports/dns_resolver.py` Protocol stub files. The stubs in + `vultron/adapters/driven/delivery_queue.py` and `dns_resolver.py` already + reference these as their port interfaces but the files do not yet exist. + No implementation logic required — Protocol class definitions only. Done when + both files exist in `core/ports/` and the driven adapter stubs can import from + them without errors. + +- [x] **P70-4**: Move `vultron/api/v2/datalayer/tinydb_backend.py` (the TinyDB + implementation) to `vultron/adapters/driven/activity_store.py`. Leave a + backward-compat re-export shim at the old path. Update `api/v2/datalayer/abc.py` + shim to re-export from the new location. Done when `TinyDbDataLayer` lives in + `adapters/driven/`, all imports resolve, and tests pass. + +- [x] **P70-5**: Remove shims and update all remaining callers to import + `TinyDbDataLayer` from `adapters/driven/` and `DataLayer` from + `core/ports/activity_store`. Delete `api/v2/datalayer/abc.py` and the + `api/v2/datalayer/tinydb.py` re-export shim. Done when no module imports from + `vultron.api.v2.datalayer.*` and tests pass. **Depends on P70-4.** -**Reference**: `specs/architecture.md` ARCH-01 through ARCH-08, -`notes/architecture-review.md` V-01 to V-11, R-01 to R-06 - -Significant architectural work. Work in dependency order: R-01 → R-02 → R-03/R-04 -→ R-05/R-06. Each task corresponds to a remediation step in -`notes/architecture-review.md`. - -- [ ] **ARCH-1.1** (R-01): Separate `MessageSemantics` from AS2 structural enums - in `vultron/enums.py`; move `MessageSemantics` to `vultron/core/models/events.py` - (ARCH-02-001, V-01). Update all imports. Tests pass. - -- [ ] **ARCH-1.2** (R-02): Introduce `InboundPayload` domain type in the core - layer; remove AS2 type from `DispatchActivity.payload` (ARCH-01-002, V-02, - V-03). Update `DispatchActivity`, all handlers, and `verify_semantics`. - Tests pass. - -- [ ] **ARCH-1.3** (R-03 + R-04): Consolidate parsing and extraction — move - `parse_activity` from router into `wire/as2/parser.py`; consolidate - `find_matching_semantics` + `ActivityPattern.match()` into - `wire/as2/extractor.py`; remove second `find_matching_semantics` call in - `verify_semantics` (ARCH-03-001, ARCH-07-001, V-04 through V-08). Tests pass. - -- [ ] **ARCH-1.4** (R-05 + R-06): Inject DataLayer via port; move - `semantic_handler_map.py` to adapter layer (ARCH-04-001, V-09, V-10). - Handlers receive `DataLayer` port object via DI; `get_datalayer()` no longer - called directly in handler bodies. Tests pass. +--- -**Note**: ARCH-1.1 through ARCH-1.4 collectively satisfy PRIORITY 150 and -facilitate cleaner actor independence (PRIORITY 100) implementation. +### Phase PRIORITY-75 — api/v2 Business Logic → core/use_cases/ + +**Reference**: `plan/PRIORITIES.md` PRIORITY 60/65 ("continue hex arch refactor"), +`notes/architecture-ports-and-adapters.md`, +`vultron/core/use_cases/__init__.py` (stub docstring) + +**Must precede**: PRIORITY-100 (driving adapters need clean use-case interface). +**Blocked by**: PRIORITY-70 (use cases call core ports; DataLayer must be +fully relocated first). + +- [x] **P75-1**: Define the `VultronEvent` domain event base type and initial + subclasses (e.g., `ReportCreatedEvent`, `CaseEngagedEvent`, `EmbargoInvitedEvent`) + in `vultron/core/models/events.py`. These replace `DispatchActivity` as the + input type for use-case callables. Done when domain event types cover the + 38 handler semantics, have no wire or adapter imports, and pass type checks. + +- [x] **P75-2**: Extract handler business logic from + `vultron/api/v2/backend/handlers/*.py` into `vultron/core/use_cases/`. Each + handler file (`report.py`, `case.py`, `embargo.py`, `participant.py`, etc.) + gets a matching module in `core/use_cases/` containing plain callables that + accept a `VultronEvent` and a `DataLayer` port. The adapter-layer handler + becomes a thin delegate: verifies semantics, builds the domain event, calls + the use case. Done when `core/use_cases/` covers all 38 use cases, handlers + import from `core/use_cases/`, and tests pass. **Depends on P75-1.** + + > ⚠️ **Post-P75-2 architecture tangles** (resolve before P75-3): + > The dispatch pipeline has residual wire coupling, vestigial handler delegates, + > naming inconsistency (Activity vs Event), and the dispatcher has not yet been + > modelled as a formal driving port. P75-2a–2c resolve these before + > trigger-service extraction adds more code on top. + +- [x] **P75-2a** — Core domain model audit and enrichment: Audit every `Vultron*` + domain type in `vultron/core/models/vultron_types.py` against its wire + counterpart — `VultronReport` vs `VulnerabilityReport`, `VultronCase` vs + `VulnerabilityCase`, `VultronEmbargoEvent` vs `EmbargoEvent`, + `VultronParticipant` vs `CaseParticipant`, `VultronNote` vs `as_Note`, + `VultronCaseStatus` vs `CaseStatus`, `VultronParticipantStatus` vs + `ParticipantStatus`, `VultronActivity` vs `as_Activity`. For each pair, + identify every field present in the wire model but absent from the domain + model — especially pass-through fields (`content`, `summary`, `url`, `tag`, + `media_type`, `context`, etc.) that may appear in real activities but are not + yet represented. Add the missing fields to the domain models. Update + `extract_intent()` in `vultron/wire/as2/extractor.py` to populate all new + fields during wire-to-domain translation. Add or update tests that verify the + new fields survive the wire-to-domain round-trip. Done when every semantically + relevant wire-model field is captured in the corresponding domain model or + documented as intentionally excluded, and tests pass. + **Must precede P75-2b** — removing `wire_object` pass-through (P75-2b) only + makes sense once domain models contain all the data use cases need. + **Depends on P75-1, P75-2.** + +- [x] **P75-2b** — Remove wire coupling from the dispatch envelope and rename + `DispatchActivity` → `DispatchEvent`: + - Rename `DispatchActivity` to `DispatchEvent` in `vultron/types.py`. "Activity" + is a wire-layer concept; "Event" is a domain-layer concept. Update all + references (dispatcher, inbox handler, handler files, tests, specs, AGENTS.md). + Note: `EmbargoEvent` is an AS2 object type, not a `VultronEvent` subclass — + take care with naming in that vicinity. + - Remove `wire_activity: Any` and `wire_object: Any` from `DispatchEvent`. + These opaque wire fields leak the wire layer into the dispatch envelope. + Once domain models are enriched (P75-2a), the `VultronEvent` payload carries + all data use cases need. + - Remove `wire_object` and `wire_activity` keyword parameters from every use + case function in `vultron/core/use_cases/`. Use cases must operate on + `VultronEvent` + `DataLayer` only. + - Update `prepare_for_dispatch()` in `inbox_handler.py` to not carry wire + objects into the envelope. + - Update the `BehaviorHandler` Protocol in `vultron/types.py` (or wherever it + lands after P75-2c) to use `DispatchEvent`. + - Update `specs/dispatch-routing.md` and `specs/handler-protocol.md` to reflect + the rename and the removal of wire fields. + - Done when `DispatchActivity` is fully renamed to `DispatchEvent`, it has no + wire-layer fields, and no use case function accepts wire objects. Tests pass. + **Depends on P75-2a.** + +- [ ] **P75-2c** — Model dispatcher as formal driving port, flatten the handler + adapter layer, and rename pattern objects: + - **Driving port**: Move the `ActivityDispatcher` Protocol from `vultron/types.py` + to `vultron/core/ports/dispatcher.py`. A driving port is an interface the core + *exposes* for adapters to call into it; defining it in `core/ports/` alongside + `DataLayer` makes this role explicit and makes the concrete dispatcher + injectable (e.g., in tests). Signature: `dispatch(event: VultronEvent, dl: + DataLayer) -> None` (after P75-2b removes wire fields from `DispatchEvent`). + - **Routing table to core**: Move `SEMANTICS_HANDLERS` from + `vultron/api/v2/backend/handler_map.py` to `vultron/core/use_cases/use_case_map.py`. + This mapping from `MessageSemantics` (domain) to use case callables (domain) + is domain knowledge, not adapter configuration. The driving-adapter inbox + handler just calls the port; it need not know which use case handles which + semantic. + - **Dispatcher implementation**: Move `DispatcherBase` / `DirectActivityDispatcher` + from `vultron/behavior_dispatcher.py` to `vultron/core/` (e.g., + `vultron/core/dispatcher.py`) or to `vultron/adapters/driving/dispatcher.py`. + Document the choice. The inbox adapter instantiates the concrete dispatcher + and injects it (removing the module-level singleton pattern). + - **Flatten handler layer**: Update `SEMANTICS_HANDLERS` (now in + `core/use_cases/use_case_map.py`) to map `MessageSemantics` directly to use + case callables. The `vultron/api/v2/backend/handlers/` shim modules become + dead code; delete them. Confirm the `@verify_semantics` guard is either + absorbed into the dispatcher (type assertion before invoking use case) or + replaced by static type checking (mypy/pyright). + - **Pattern naming**: Rename every `ActivityPattern` instance in + `SEMANTICS_ACTIVITY_PATTERNS` in `vultron/wire/as2/extractor.py` to use a + `Pattern` suffix (e.g., `CreateReport` → `CreateReportPattern`, + `EngageCase` → `EngageCasePattern`). This distinguishes pattern-matching + objects from similarly-named `Activity` and `Event` types. + - Update AGENTS.md, specs, and inline documentation to reflect the new + driving-port model and the absence of the handler shim layer. + - Done when `ActivityDispatcher` is defined in `core/ports/`, routing table is + in `core/use_cases/use_case_map.py`, handler shim modules are deleted, all + pattern objects use `Pattern` suffix, and tests pass. + **Depends on P75-2b.** + +- [ ] **P75-3**: Migrate trigger-service logic from + `vultron/api/v2/backend/trigger_services/` to `vultron/core/use_cases/`. + The trigger router stays in `api/v2/routers/trigger_*.py`; the service layer + moves to `core/use_cases/` as callable functions accepting domain types + a + `DataLayer` port. Done when trigger services in `api/v2/backend/trigger_services/` + are either deleted or reduced to thin delegates, and tests pass. + **Depends on P75-1, P75-2.** + +- [ ] **P75-4**: Update driving adapter stubs (`vultron/adapters/driving/cli.py`, + `vultron/adapters/driving/mcp_server.py`) to call `core/use_cases/` callables + directly with an injected `DataLayer`, without going through HTTP. Done when + the CLI and MCP adapters exercise the same code paths as the HTTP inbox adapter. + **Depends on P75-2, P75-3.** + +- [ ] **P75-5**: Decide disposition of `vultron/api/v1/`. The v1 API is a + vocabulary-examples HTTP adapter (thin routers over `wire/as2/vocab/examples/`; + no business logic). Options: (a) keep as-is with a clear "vocabulary showcase" + label, (b) merge into `api/v2` as a `/examples/` subrouter, or (c) deprecate + and remove. Done when a decision is recorded in an ADR or issue and the code + reflects the decision. --- @@ -247,6 +421,8 @@ facilitate cleaner actor independence (PRIORITY 100) implementation. `specs/case-management.md` CM-01, `notes/domain-model-separation.md` (Per-Actor DataLayer Isolation Options) +**Blocked by**: PRIORITY-70 + - [ ] **ACT-1**: Draft ADR for per-actor DataLayer isolation — document options (Option B: TinyDB namespace prefix; MongoDB community for production), trade-offs, and migration path. The MongoDB approach is recommended for diff --git a/plan/PRIORITIES.md b/plan/PRIORITIES.md index 72df826f..cf11f094 100644 --- a/plan/PRIORITIES.md +++ b/plan/PRIORITIES.md @@ -52,6 +52,48 @@ injection in a way that is consistent with the hexagonal architecture. This will entail some refactoring of the code base to reorganize modules and split out responsibilities more cleanly. +## Priority 60: Continue hexagonal architecture refactor + +The hexagonal architecture refactor is a large task that will require multiple +iterations to fully implement. Some basics are in place (core and wire +packages exist but are not fully populated). Some other packages just need +to be relocated (e.g., `vultron/as_vocab` to `wire/as2`, `vultron/behaviors` +to `core/behaviors`, etc.) but splitting `vultron/api` into adapters will +take a little more finesse. The API layer has a lot of domain logic mixed in +with routing and request handling, which properly belong in ports or adapters. +`vultron/enums.py` needs to be split across core, ports, and adapters as +well. The focus here should be on separating concerns and moving towards a +cleaner architecture overall, starting to put the pieces in place to avoid +large refactors later. + +## Priority 65: Address all outstanding architecture violations in `notes/architecture-review.md` + +Following an architecture review of the codebase, we have identified a +number of architecture violations that need to be addressed. These are +documented in `notes/architecture-review.md`. Addressing these violations is +important so that we can move forward with a clean architecture that +properly separates concerns from the front-end (driving adapters), wire, +core (use cases, etc.), and back-end layers (driven ports and adapters). +This continues Priority 50 and 60, and pre-empts or blends in with Priority 70 +below. Use the architecture review notes as a checklist to identify and +address each violation, ensuring that tasks are grouped appropriately in +`plan/IMPLEMENTATION_PLAN.md` to avoid excessive fragmentation of related work. + +Note that the conversion to 'VultronEvent' domain events is considered a key +part of this priority, as it must be addressed before we can fully separate +the driving adapters from the core use cases (events). + +## Priority 70: DataLayer refactor into ports and adapters + +The DataLayer implementation should be refactored to become a port (Protocol), +with the TinyDB implementation as a (driven) adapter that implements it. +Move files around as needed to fit the new structure cleanly. This sets us +up for adding new db backends in the future without needing to change core +logic. (That part is mostly already true since the DataLayer is reasonably +well abstracted already, but we still need to make sure the files and their +contents are +organized to reflect the architecture.) + ## Priority 100: Actor independence Each actor exists in its own behavior tree domain. So Actor A and Actor B @@ -60,6 +102,10 @@ through the Vultron Protocol through passing ActivityStreams messages with defined semantics. This allows us to have a clean model of individual actors making independent decisions based on their own internal state. +Implementation Phase OUTBOX-1 logically falls under this priority, because +it's part of getting messages flowing between actors. But it does not +fully achieve this goal by itself. + ## Priority 200: Case Actor as source of truth for case state @@ -118,13 +164,12 @@ it in the new codebase using `py_trees` as the foundation. The underlying We are going to want to allow for the possibility of agentic AI integration into the vultron coordination process in the future. How this will happen is -still an open question. - -One possibility we can imagine coordination agents that behave as -ActivityPub Actors and participate in cases as CaseParticipants alongside +still an open question. One possibility we can imagine coordination agents +that behave as ActivityPub Actors and participate in cases as CaseParticipants alongside humans. -We want to support agentic AI agents interacting with cases as well on the +A more likely scenario is that we want to support agentic AI agents +interacting with cases as well on the backend (i.e., not as ActivityPub Actors, but as API or command line clients.) We may have local agents that interact directly with the behavior trees or other internal system components via MCP. This would @@ -133,6 +178,8 @@ architecture. These agents would not be ActivityPub Actors and would not directly participate in cases, but would instead be more like assistants to human participants who are directing them to perform specific tasks. +`AR-09-001` through `AR-09-004` and similar tasks will fall here. + We will need to design the system in a way that allows for either of these possibilities to be implemented in the future without requiring major refactoring. diff --git a/prompts/ARCHITECTURE_REVIEW_prompt.md b/prompts/ARCHITECTURE_REVIEW_prompt.md new file mode 100644 index 00000000..b545f693 --- /dev/null +++ b/prompts/ARCHITECTURE_REVIEW_prompt.md @@ -0,0 +1,135 @@ +You are reviewing this codebase for adherence to the architecture described in +`notes/architecture-ports-and-adapters.md`, as refined in +`specs/architecture.md`. Additional notes from a previous review are found +in `notes/architecture-review.md`. Read those document in full +before examining any code. Use it as your ground truth throughout. + +### Context to hold in mind + +This project has a specific layering challenge worth understanding before you +start: + +The case management domain was designed first. Activity Streams 2.0 was later +adopted as the wire format because it has a 1:1 semantic match with the domain +vocabulary. This means AS2 concepts and domain concepts look very similar — they +use the same words for the same things. The prototype may have allowed this +similarity to blur the boundary between the wire layer and the domain. Your job +is to find where that blurring has occurred. + +The architecture defines three distinct concerns that must not be mixed: + +1. **Structural AS2 parsing** — is this valid AS2 JSON? Lives in `wire/as2/`. +2. **Semantic extraction** — what does this AS2 message mean in domain terms? + Also lives in `wire/as2/extractor.py`, and only there. +3. **Domain logic** — what do we do about it? Lives in `core/`. Operates on + domain types only, with no awareness of AS2. + +### What to look for + +Source code is in `vultron/`. Note that `vultron/core`, `vultron/wire`, and +`vultron/adapters` are partially populated, so you will first need to identify +how existing files will map in to the new structure. + +**In `core/` files:** + +- Any import from `wire/`, `pyld`, `rdflib`, or any AS2/JSON-LD library. This is + a hard violation — the domain must have zero wire format awareness. +- Any import from adapter frameworks: `fastapi`, `typer`, `mcp`, `httpx`, + `celery`, `nats`. +- Functions that accept raw dicts, AS2 types, or JSON strings instead of domain + Pydantic models. +- Direct instantiation of DB connections, HTTP clients, or queue clients (these + should come in via injected port implementations). +- AS2 vocabulary appearing in logic — checking `activity.type == "Offer"` or + similar inside a service function. That check belongs in the extractor, not + the domain. +- `MessageSemantics` being defined outside `core/models/events.py`. + +**In `wire/as2/` files:** + +- Domain logic appearing here — case handling, participant authorization, + journal sequencing. If the wire layer is making decisions about what to *do* + with a message, that logic belongs in the core. +- AS2-to-domain vocabulary mapping scattered across multiple files. The mapping + must be consolidated in `extractor.py`. If you find `activity.type` being + inspected anywhere else in the wire layer, flag it. +- The serializer constructing domain objects rather than translating them — the + domain should produce events, the serializer converts them to AS2. + +**In `adapters/driving/` (especially the HTTP inbox):** + +- AS2 parsing or semantic extraction happening inline inside the endpoint + handler, mixed with dispatch logic. These should be separate stages: parse → + extract → dispatch. +- Domain logic appearing in the endpoint handler. +- The endpoint inspecting AS2 activity fields to decide what to do — that's the + extractor's job. + +**In `adapters/connectors/`:** + +- Case handling logic, authorization decisions, or journal management inside a + connector plugin. Connectors must only translate between tracker events and + domain events. +- Connectors being imported directly by core rather than discovered via entry + points. + +**In tests:** + +- Core tests that instantiate HTTP clients, parse AS2 JSON, or invoke FastAPI + endpoints. Core tests should call service functions directly with domain + Pydantic objects. +- Wire tests that invoke domain services — wire tests should stop at the + extractor output. +- Tests that are impossible to run without a real database or queue (indicates + port injection isn't being used). + +### The specific thing to locate + +There is likely a place in the codebase — probably in or near the inbox +handler — where AS2 parsing, semantic matching, and handler dispatch are mixed +together. The architecture calls for these to be three distinct stages. Find +that code, describe exactly what is mixed, and specify how it should be +separated. + +### Output format + +Output your findings into `notes/architecture-review.md`, updating the +existing content to reflect your current findings. You can remove prior +findings that have been addressed, but do not remove prior findings that are +not yet addressed in the code. Produce your findings in three sections: + +**1. Violations** + +For each violation: + +- File and function/line +- Which rule it breaks (by number, from + `notes/architecture-ports-and-adapters.md` and `specs/architecture.md`) +- Severity: Critical (core depends on wire format or framework), Major (logic in + wrong layer), Minor (convention or organisation) +- One sentence explaining why it is a violation + +**2. Remediation Plan** + +For each Critical or Major violation: + +- What moves where +- Any new abstraction needed (new file, new interface, new type) +- Rough sketch of corrected code if helpful +- Dependencies between remediations — if B requires A first, say so explicitly + +**3. What Is Already Clean** + +Note code that already follows the architecture correctly. Establishes which +patterns to replicate and confirms the review is balanced. + +### Tone + +Be specific. "The core has too much responsibility" is not useful. "Line 47 of +`adapters/driving/http_inbox.py` inspects `activity.type` to select a handler, +which is Rule 4 violation" is useful. If something is ambiguous — genuinely +unclear whether it belongs in the wire layer or the domain — say so and explain +the ambiguity rather than guessing. + +DO NOT MODIFY ANY CODE. This is a review, not a refactor. Your job is to +identify what's wrong, not to fix it. \ No newline at end of file diff --git a/prompts/BUILD_prompt.md b/prompts/BUILD_prompt.md index 6e42afb1..0188312a 100644 --- a/prompts/BUILD_prompt.md +++ b/prompts/BUILD_prompt.md @@ -5,7 +5,7 @@ Objective: Complete the highest-priority pending implementation task. - plan/PRIORITIES.md — current implementation priorities. - specs/*.md (start with specs/README.md) — authoritative requirements. - plan/IMPLEMENTATION_PLAN.md — current tasks and status. - - plan/IMPLEMENTATION_NOTES.md — implementation notes and constraints. + - plan/IMPLEMENTATION_NOTES.md — lessons learned and constraints (not implementation history). - notes/*.md (start with notes/README.md) — relevant lessons learned. 2. Select Work @@ -44,7 +44,7 @@ Objective: Complete the highest-priority pending implementation task. 5. If Validation Succeeds - Mark the task complete in plan/IMPLEMENTATION_PLAN.md. - - Update plan/IMPLEMENTATION_NOTES.md with relevant implementation details. + - Append a 'what was done' summary to plan/IMPLEMENTATION_HISTORY.md; record any lessons learned or constraints in plan/IMPLEMENTATION_NOTES.md. - `git add` modified files and commit with a clear, specific message. 6. Exit. Only one task should be completed per run. diff --git a/pyproject.toml b/pyproject.toml index ac1fb8f2..0ee6212b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ dev = [ "mypy>=1.18.2", "pandas-stubs>=2.3.2.250827", "pre-commit>=4.3.0", + "pyright>=1.1.408", "pytest>=8.4.2", "types-networkx>=3.5.0.20250918", ] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..7dabd838 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "include": ["vultron", "test"], + "exclude": ["**/__pycache__", "**/node_modules", "site", "docs"], + "typeCheckingMode": "basic", + "pythonVersion": "3.12", + "reportMissingImports": true, + "reportMissingTypeStubs": false +} diff --git a/specs/README.md b/specs/README.md index ed4b9697..e8114df3 100644 --- a/specs/README.md +++ b/specs/README.md @@ -26,7 +26,7 @@ Specifications are organized by topic with minimal overlap. Cross-references lin 1. **`inbox-endpoint.md`** - FastAPI HTTP endpoint accepting ActivityStreams activities 2. **`message-validation.md`** - ActivityStreams 2.0 structure and semantic validation 3. **`semantic-extraction.md`** - Pattern matching to determine message semantics -4. **`dispatch-routing.md`** - Routing DispatchActivity to handler functions +4. **`dispatch-routing.md`** - Routing DispatchEvent to handler functions 5. **`handler-protocol.md`** - Handler function contract and implementation patterns **Behavior Tree Integration** (optional for complex workflows): @@ -66,7 +66,8 @@ Specifications are organized by topic with minimal overlap. Cross-references lin **Quality Attributes**: - **`idempotency.md`** - Duplicate detection and idempotent processing -- **`testability.md`** - Test coverage requirements and test organization +- **`testability.md`** - Test coverage requirements, test organization, + architecture boundary tests (TB-10, `PROD_ONLY`) ### Response Generation @@ -91,7 +92,10 @@ Specifications are organized by topic with minimal overlap. Cross-references lin ### Code Standards - **`code-style.md`** - Python formatting, import organization, circular import - prevention, optional-field non-emptiness (CS-08-001), code reuse (CS-09-001) + prevention, optional-field non-emptiness (CS-08-001), code reuse (CS-09-001), + typed port/adapter interfaces (CS-10-001), domain event naming convention + (`FooActivity` vs `FooEvent`, CS-10-002), type annotation strictness + (no `Any`, CS-11-001), domain-centric class naming (CS-12-001) - **`tech-stack.md`** - Normative technology constraints: runtime, persistence, tooling, and code quality tooling (including pyright gradual adoption, IMPL-TS-*) - **`meta-specifications.md`** - How to write and maintain specifications @@ -105,7 +109,8 @@ Specifications are organized by topic with minimal overlap. Cross-references lin - **`project-documentation.md`** - Documentation file structure and purpose - **`prototype-shortcuts.md`** - Permissible shortcuts for the prototype stage, - including domain model separation deferral (PROTO-06) + including domain model separation deferral (PROTO-06) and performance + testing deferral (PROTO-07) - **`agentic-readiness.md`** - API and CLI requirements for automated agent integration --- @@ -167,6 +172,7 @@ is reserved for `testability.md`). | `IMPL-TS` | `tech-stack.md` | | `MV` | `message-validation.md` | | `OB` | `observability.md` | +| `OID` | `object-ids.md` | | `OX` | `outbox.md` | | `PD` | `project-documentation.md` | | `PROTO` | `prototype-shortcuts.md` | @@ -220,7 +226,7 @@ source. See `plan/IMPLEMENTATION_PLAN.md` for detailed implementation status by specification. -**Summary (2026-03-09)**: +**Summary (2026-03-10)**: - ✅ **Core infrastructure complete**: Semantic extraction, dispatch routing, handler protocol, data layer @@ -232,16 +238,23 @@ See `plan/IMPLEMENTATION_PLAN.md` for detailed implementation status by specific `plan/IMPLEMENTATION_PLAN.md` - ✅ **Unified demo CLI complete** (`vultron-demo`): See `specs/demo-cli.md` and `plan/IMPLEMENTATION_PLAN.md` (Phase DEMO-4) -- ✅ **TECHDEBT-6 complete**: `vultron/scripts/vocab_examples.py` shim removed +- ✅ **Triggerable behaviors fully implemented** (PRIORITY 30 complete): + All 9 trigger endpoints (`validate-report`, `invalidate-report`, + `reject-report`, `engage-case`, `defer-case`, `close-report`, + `propose-embargo`, `evaluate-embargo`, `terminate-embargo`) +- ✅ **Hexagonal architecture Phase 1 complete** (PRIORITY 50 + ARCH-CLEANUP): + `MessageSemantics` in core, `InboundPayload` domain type, wire/as2 + extractor and parser, handler map in adapter layer, AS2 enums in wire + layer, shims deleted, `isinstance` AS2 checks removed (V-01 through V-12) +- ✅ **Package relocation P60-1, P60-2 complete**: `vultron/as_vocab/` → + `vultron/wire/as2/vocab/`; `vultron/behaviors/` → `vultron/core/behaviors/` - ✅ **Object model gap closed**: `VulnerabilityRecord`, `CaseReference`, `EmbargoPolicy`, `VultronActorMixin` models added (SC-1.1, SC-1.2, EP-1.1, EP-1.2) -- ✅ **736 tests passing**, 0 xfailed (2026-03-06) +- ✅ **822 tests passing**, 0 xfailed (2026-03-10) - ⚠️ **Production readiness partial**: Request validation, error responses need work -- ⚠️ **Triggerable behaviors partially implemented**: P30-1 through P30-3 - complete (`validate-report`, `invalidate-report`, `reject-report`, - `engage-case`, `defer-case`); P30-4 through P30-6 remain +- ❌ **P60-3 not started**: `vultron/adapters/` package stub pending - ❌ **Response generation not started**: See `response-format.md` - ❌ **Outbox delivery not implemented**: See `outbox.md` OX-03, OX-04 diff --git a/specs/agentic-readiness.md b/specs/agentic-readiness.md index de6a7d4d..4e1793b1 100644 --- a/specs/agentic-readiness.md +++ b/specs/agentic-readiness.md @@ -93,6 +93,35 @@ interfaces that support agentic workflows. - `AR-08-005` `PROD_ONLY` Long-running CLI commands SHOULD support `--wait` / `--no-wait` flags; `--no-wait` returns the job object immediately +## MCP Server Adapter (MAY) + +The Model Context Protocol (MCP) server is a driving adapter that exposes the +Vultron core to AI agent tool calls. Like the CLI and HTTP inbox, the MCP +server translates external requests into domain use-case invocations without +containing domain logic. + +**Note**: These requirements are later-prototype items, not production-only. +They become relevant once `vultron/core/use_cases/` is formalized (P60-3+). +They are not tagged `PROD_ONLY` because the MCP adapter is a natural extension +of the hexagonal architecture that will be valuable even in prototype-stage +multi-actor scenarios. + +- `AR-09-001` A local MCP server adapter MAY be provided at + `vultron/adapters/driving/mcp_server.py`, exposing Vultron use cases as MCP + tools +- `AR-09-002` Each MCP tool MUST map 1:1 to a domain use case in + `vultron/core/use_cases/`, with no business logic in the adapter itself +- `AR-09-003` `PROD_ONLY` The MCP server MUST authenticate tool calls using the + same actor identity model as the HTTP inbox +- `AR-09-004` MCP tool responses MUST use the same structured JSON format as + CLI `--output json` responses, enabling consistent AI agent parsing + +The MCP adapter is architecturally equivalent to the CLI adapter: both are +driving adapters that invoke the same core use cases. The MCP server allows AI +agents to use Vultron as a tool in automated vulnerability coordination +workflows. See `notes/architecture-ports-and-adapters.md` (Adapter Categories, +Driving Adapters) for the architecture context. + ## Verification ### AR-01-001, AR-01-002, AR-01-003 Verification diff --git a/specs/architecture.md b/specs/architecture.md index 04a1c4ed..a4a8247e 100644 --- a/specs/architecture.md +++ b/specs/architecture.md @@ -13,7 +13,8 @@ core through defined **ports**, via thin **adapters**. **Cross-references**: `code-style.md` CS-05-001 (circular import prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), `notes/domain-model-separation.md` (wire/domain/persistence separation), -`notes/architecture-review.md` (current violation inventory) +`notes/architecture-review.md` (violation inventory and remediation history), +`docs/adr/0009-hexagonal-architecture.md` (decision rationale) --- @@ -25,13 +26,17 @@ prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), connector libraries, AS2 libraries (`pyld`, `rdflib`, JSON-LD tooling) - **Rationale**: Keeps domain logic replaceable without touching the wire or transport layer - - **Current state**: Deferred to post-prototype; see PROTO-06-001 + - **Current state**: Partially achieved. `vultron/core/` has no wire/framework + imports; `vultron/api/` still uses FastAPI and imports from `vultron/wire/`. + Full isolation deferred to post-prototype; see PROTO-06-001 - `ARCH-01-002` Core functions MUST accept and return domain types only - Raw dicts, AS2 types, JSON strings, and framework objects MUST NOT enter the domain - The inbound pipeline MUST complete parse → extract steps before calling into core - - **Current state**: Deferred to post-prototype; see PROTO-06-001 + - **Current state**: `InboundPayload` domain type introduced (ARCH-1.2); + parse → extract pipeline stages in `vultron/wire/as2/`. Full core isolation + deferred to post-prototype; see PROTO-06-001 - `ARCH-01-003` The `wire/` layer (AS2 parser, semantic extractor) MUST NOT contain handler logic, case management, or journal management - **Rationale**: Wire format concerns are structurally distinct from @@ -45,8 +50,9 @@ prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), in the system, as the domain understands it - Wire layer pattern maps are an implementation detail of the extractor, not part of the domain definition - - **Current location**: `vultron/enums.py` (mixed with AS2 structural - enums — see `notes/architecture-review.md` V-01 for remediation plan) + - **Current location**: `vultron/core/models/events.py` (remediated in + ARCH-1.1); re-exported from `vultron/enums.py` for compatibility. AS2 + structural enums moved to `vultron/wire/as2/enums.py` (ARCH-CLEANUP-2). ## Semantic Extractor (MUST) @@ -56,9 +62,9 @@ prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), - Handler code MUST NOT inspect AS2 types to infer message meaning - **Rationale**: Isolates the single seam where wire format changes are absorbed - - **Current state**: Extraction is split across `semantic_map.py` and - `activity_patterns.py`; see `notes/architecture-review.md` V-04, V-05 - for remediation plan + - **Current state**: ✅ Achieved. `vultron/wire/as2/extractor.py` is the + sole location of AS2-to-domain vocabulary mapping (ARCH-1.3, + ARCH-CLEANUP-1). Handler code no longer inspects AS2 types (ARCH-CLEANUP-3). ## Driven Adapter Injection (MUST) @@ -68,8 +74,9 @@ prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), directly - **Rationale**: Enables testing core logic with in-memory ports and swapping production backends without touching domain code - - **Current state**: Handlers call `get_datalayer()` directly; see - `notes/architecture-review.md` V-10 for remediation plan + - **Current state**: ✅ Achieved. All handlers receive `dl: DataLayer` via + parameter injection (ARCH-1.4). `get_datalayer()` is no longer called + inside handler bodies. ## Connector Plugins (MUST) @@ -85,18 +92,20 @@ prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), unit without touching the core or adapter layers - If a change to the wire format requires changes in the core, a boundary has been violated - - **Current state**: Not yet achieved; AS2 types currently present in - `DispatchActivity.payload` (see `notes/architecture-review.md` V-02) + - **Current state**: Substantially achieved. `DispatchEvent.payload` is + now typed as `InboundPayload` (domain type), not `as_Activity` (ARCH-1.2). + Full wire replaceability requires completing P60-3 (adapters package). ## Handler Isolation (MUST) - `ARCH-07-001` Handler functions MUST NOT re-invoke semantic extraction - The semantic type MUST be pre-computed and carried in the dispatch - wrapper (`DispatchActivity.semantic_type`) + wrapper (`DispatchEvent.semantic_type`) - Semantic verification decorators MUST compare `dispatchable.semantic_type` directly, not re-run pattern matching - - **Current state**: `verify_semantics` decorator re-invokes - `find_matching_semantics`; see `notes/architecture-review.md` V-04 + - **Current state**: ✅ Achieved. `verify_semantics` decorator now compares + `dispatchable.semantic_type` directly (ARCH-1.2/ARCH-1.3); no second + invocation of `find_matching_semantics`. ## Driving Adapter Boundary (MUST) @@ -106,8 +115,9 @@ prevention), `prototype-shortcuts.md` PROTO-06-001 (domain model deferral), not responsibilities of the driving adapter itself - **Rationale**: Any driving adapter that needs to ingest AS2 can reuse the wire pipeline; there is no duplication - - **Current state**: `parse_activity` lives in the router - (`api/v2/routers/actors.py`); see `notes/architecture-review.md` V-06 + - **Current state**: ✅ Achieved. `parse_activity()` is in + `vultron/wire/as2/parser.py`; the router calls it as a thin wrapper + (ARCH-1.3). --- @@ -148,23 +158,33 @@ Use this checklist during code review to catch boundary violations. --- -## Deferred to Post-Prototype - -The following architectural rules are aspirational targets. Achieving them -requires a refactoring effort documented in -`notes/architecture-review.md`. The prototype MAY violate these rules while -the refactoring is in progress: - -- ARCH-01-001, ARCH-01-002 (core isolation from wire/framework) — see - PROTO-06-001 -- ARCH-03-001 (extractor as sole mapping point) — partial: extraction - logic is split but not duplicated in handler business logic -- ARCH-04-001 (adapter injection) — all handlers call `get_datalayer()` - directly; injection is deferred -- ARCH-06-001 (wire replaceability) — not yet achieved -- ARCH-07-001 (no re-invocation in handlers) — `verify_semantics` - currently re-runs extraction; a one-line fix (compare - `dispatchable.semantic_type` directly) is the intended resolution - -See `notes/architecture-review.md` for the full violation inventory and -remediation plan (R-01 to R-06). +## Remediation Status + +The following architectural requirements had known violations that have been +remediated through incremental refactoring (ARCH-1.1 through ARCH-CLEANUP-3). +See `docs/adr/0009-hexagonal-architecture.md` for full violation inventory. + +**Remediated (ARCH-1.x and ARCH-CLEANUP-x):** + +- ARCH-02-001 (`MessageSemantics` in core) — ✅ ARCH-1.1 +- ARCH-03-001 (sole mapping point) — ✅ ARCH-1.3, ARCH-CLEANUP-1 +- ARCH-04-001 (adapter injection) — ✅ ARCH-1.4 +- ARCH-06-001 (wire replaceability) — ✅ substantially achieved (ARCH-1.2) +- ARCH-07-001 (no re-invocation) — ✅ ARCH-1.2/1.3 +- ARCH-08-001 (parse in wire layer) — ✅ ARCH-1.3 + +**Partially deferred (PROTO-06-001):** + +- ARCH-01-001, ARCH-01-002 (full core isolation from wire/framework) — + `vultron/core/` is clean; `vultron/api/` imports from `vultron/wire/` as + expected for an adapter. Full isolation of all existing domain objects from + AS2 base types is deferred to post-prototype; see PROTO-06-001 and + `notes/domain-model-separation.md`. + +**Not yet started (PRIORITY-60):** + +- P60-3: `vultron/adapters/` package structure stub — see + `plan/IMPLEMENTATION_PLAN.md` + +See `notes/architecture-review.md` for full violation inventory and +remediation history (R-01 to R-06). diff --git a/specs/behavior-tree-integration.md b/specs/behavior-tree-integration.md index d26bccad..5107394f 100644 --- a/specs/behavior-tree-integration.md +++ b/specs/behavior-tree-integration.md @@ -44,7 +44,7 @@ SHOULD use BTs for clarity and maintainability. `id_segment` is the last path segment of the object's URI - Examples: `object_abc123`, `case_def456`, `actor_vendorco` - Current convention: `object_{last_url_segment}` (see - `vultron/behaviors/report/nodes.py`) + `vultron/core/behaviors/report/nodes.py`) - `BT-03-004` State changes MUST be committed to DataLayer on successful execution @@ -172,5 +172,6 @@ SHOULD use BTs for clarity and maintainability. - **Design Notes**: `notes/bt-integration.md` (durable design decisions, handler decision table, directionality of EvaluateCasePriority) - **ADRs**: ADR-0002 (BT rationale), ADR-0007 (dispatcher architecture) -- **Implementation**: `vultron/behaviors/` (bridge layer, helpers, report trees) +- **Implementation**: `vultron/core/behaviors/` (bridge layer, helpers, + report trees) - **Tests**: `test/behaviors/` diff --git a/specs/case-management.md b/specs/case-management.md index 7900ef13..c635be6e 100644 --- a/specs/case-management.md +++ b/specs/case-management.md @@ -405,5 +405,5 @@ the distinction between participant-specific and participant-agnostic state. - **Object IDs**: `specs/object-ids.md` - **Do Work Behaviors**: `notes/do-work-behaviors.md` - **Encryption**: `specs/encryption.md` -- **Implementation**: `vultron/as_vocab/objects/vulnerability_case.py` -- **Implementation**: `vultron/as_vocab/objects/case_status.py` +- **Implementation**: `vultron/wire/as2/vocab/objects/vulnerability_case.py` +- **Implementation**: `vultron/wire/as2/vocab/objects/case_status.py` diff --git a/specs/code-style.md b/specs/code-style.md index 1ed5f55a..57090d6e 100644 --- a/specs/code-style.md +++ b/specs/code-style.md @@ -37,7 +37,7 @@ Defines code formatting and import organization standards for Python code. ```python # Public API - comprehensive docstring -def validate_report(dispatchable: DispatchActivity) -> None: +def validate_report(dispatchable: DispatchEvent) -> None: """Validate vulnerability report and create case on acceptance. Args: @@ -85,8 +85,12 @@ def extract_id_segment(url: str) -> str: ## Circular Import Prevention (MUST) - `CS-05-001` Core modules SHALL NOT import from application layer modules - - Core modules: `behavior_dispatcher.py`, `semantic_map.py`, `semantic_handler_map.py`, `activity_patterns.py` + - Core modules: `vultron/behavior_dispatcher.py`, `vultron/core/`, `vultron/wire/` - Application layer: `api/v2/*` + - Note: `semantic_map.py`, `semantic_handler_map.py`, and `activity_patterns.py` + no longer exist as top-level modules; their contents were relocated to + `vultron/wire/as2/extractor.py` and `vultron/api/v2/backend/handler_map.py` + as part of the hexagonal architecture refactoring (ARCH-CLEANUP-1) - `CS-05-002` When circular dependencies cannot be resolved by reorganization, use lazy initialization patterns as a **last resort** - Prefer module-level imports; local imports are a code smell indicating a @@ -116,10 +120,25 @@ def extract_id_segment(url: str) -> str: ## `as_` Field Prefix Policy (SHOULD) -- `CS-07-001` Use `as_` prefix on Pydantic fields only when the plain name - would collide with a Python reserved word - - Use `as_object` instead of `object` (reserved keyword) - - Otherwise use plain field names: `actor`, not `as_actor` +- `CS-07-001` Use the `as_` prefix on Pydantic fields **only in the wire layer** + (`vultron/wire/as2/vocab/`) where it is part of the established AS2 + vocabulary convention +- `CS-07-002` In **core** (`vultron/core/`) domain model classes, do NOT use + the `as_` prefix + - The `as_` prefix on core fields is a relic of the original wire/core + blending and SHOULD be removed as core models are refactored + - For fields whose plain name collides with a Python reserved word (e.g., + `object`, `type`, `id`), use a trailing underscore: `object_`, `type_`, + `id_` + - Define a Pydantic field alias so that serialized JSON uses the clean + name without the trailing underscore: + ```python + object_: str = Field(alias="object") + ``` + - **Rationale**: The `as_` prefix leaks wire-format concerns into the + domain layer. Trailing underscore + alias is the idiomatic Python pattern + for reserved-word field names; it keeps core models readable and decoupled + from AS2 naming conventions. ## Optional Field Non-Emptiness (MUST) @@ -137,7 +156,7 @@ def extract_id_segment(url: str) -> str: - `CS-08-002` Non-empty string validation SHOULD be consolidated into a shared type alias rather than duplicated per-field validators - Define a `NonEmptyString` type (e.g., `Annotated[str, Field(min_length=1)]`) - in a shared base module (e.g., `vultron/as_vocab/base/`) + in a shared base module (e.g., `vultron/wire/as2/vocab/base/`) - Define an `OptionalNonEmptyString` type alias (e.g., `Optional[NonEmptyString]`) for nullable fields that follow CS-08-001 - Replace per-field `@field_validator` stubs that only check `if not v` or @@ -167,3 +186,63 @@ def extract_id_segment(url: str) -> str: existing model - **Rationale**: Duplicated models diverge silently over time; a hierarchy makes the relationship explicit and reduces boilerplate + +## Port and Adapter Data Exchange (MUST) + +- `CS-10-001` Data passed across port/adapter boundaries MUST use Pydantic + `BaseModel`-derived classes rather than plain `dict`s + - When a type is shared between a driving adapter and a driven adapter, + define a shared base model in `vultron/core/models/` that both sides + can import or extend + - Adapters may add adapter-specific fields by subclassing the shared model + - **Rationale**: `dict`s discard type information at the boundary, suppress + validation, and make interfaces ambiguous. Pydantic models preserve + validation, IDE support, and documentation at every layer crossing. + +## Domain Event and Wire Activity Naming (SHOULD) + +- `CS-10-002` Wire-level ActivityStreams payload classes SHOULD carry the + `Activity` suffix; domain event classes SHOULD carry the `Event` suffix + - **Wire layer** (`vultron/wire/as2/vocab/activities/`): classes named + `FooActivity` (e.g., `ReportSubmitActivity`) represent structured payloads + recognized by the semantic extractor + - **Domain layer** (`vultron/core/models/events/`): classes named `FooEvent` + (e.g., `ReportSubmittedEvent`) represent typed domain events consumed by + handlers and use cases + - Domain events that originate from received wire messages SHOULD use the + `FooReceivedEvent` subtype suffix (e.g., `ReportSubmittedReceivedEvent`) + - Domain events that originate from local actor-initiated triggers SHOULD + use the `FooTriggerEvent` subtype suffix (e.g., `ValidateReportTriggerEvent`) + - **Rationale**: Distinguishes wire representation from domain intent, + prevents accidental coupling between layers, and makes the translation + point explicit. See `notes/domain-model-separation.md` for the full + design rationale. + +## Type Annotations (MUST) + +- `CS-11-001` Code MUST NOT use `Any` in type hints when the type can be + determined + - If a type is complex, define a Pydantic model or a type alias rather than + using `Any` + - Use `Any` only as a last resort when the type is genuinely unknown or + when interfacing with untyped third-party code that cannot be typed otherwise + - When you find yourself reaching for `Any`, treat it as a signal to + refactor: the type structure may need to be made more explicit + - **Rationale**: `Any` defeats static type checking, obscures API + contracts, and hides bugs at the boundary between typed and untyped code + +## Domain Model Naming (SHOULD) + +- `CS-12-001` Core domain model class names SHOULD reflect the domain concept + they represent, not a parallel to a wire-format class name + - Instead of `VultronOffer` (a parallel to the AS2 `Offer` activity), + use a name that reflects the use case: + `CaseTransferOffer`, `ReportSubmissionOffer`, `EmbargoInvitation`, etc. + - Instead of `VultronEvent`, use a name that reflects the specific + semantic: `ReportSubmittedEvent`, `CaseCreatedEvent`, etc. + - **Rationale**: Generic wire-mirroring names obscure what an object + actually represents in the CVD domain. Domain-centric names make the + code self-documenting and reduce reliance on comments to explain intent. + - **Scope**: Applies to new classes in `vultron/core/` and to existing + classes when they are refactored; do not rename existing classes + incidentally while working on unrelated changes diff --git a/specs/dispatch-routing.md b/specs/dispatch-routing.md index b4a90683..37c65349 100644 --- a/specs/dispatch-routing.md +++ b/specs/dispatch-routing.md @@ -2,7 +2,7 @@ ## Overview -After semantic extraction, the dispatcher routes DispatchActivity objects to appropriate handler functions. The dispatcher may execute handlers synchronously (DirectActivityDispatcher) or asynchronously (queue-based). +After semantic extraction, the dispatcher routes DispatchEvent objects to appropriate handler functions. The dispatcher may execute handlers synchronously (DirectActivityDispatcher) or asynchronously (queue-based). **Source**: Design documents, handler protocol requirements @@ -11,7 +11,7 @@ After semantic extraction, the dispatcher routes DispatchActivity objects to app ## Dispatcher Protocol (MUST) - `DR-01-001` All dispatcher implementations MUST implement ActivityDispatcher protocol -- `DR-01-002` Dispatchers MUST pass complete DispatchActivity objects when invoking handlers +- `DR-01-002` Dispatchers MUST pass complete DispatchEvent objects when invoking handlers - `DR-01-003` Dispatchers MUST invoke `verify_semantics` decorator checks during handler execution ## Handler Lookup (MUST) @@ -37,7 +37,7 @@ After semantic extraction, the dispatcher routes DispatchActivity objects to app ### DR-01-001, DR-01-002, DR-01-003 Verification - Unit test: Verify DirectActivityDispatcher implements ActivityDispatcher protocol -- Unit test: Verify dispatcher passes complete DispatchActivity to handlers +- Unit test: Verify dispatcher passes complete DispatchEvent to handlers - Unit test: Verify decorator validation occurs during dispatch ### DR-02-001, DR-02-002 Verification @@ -60,7 +60,7 @@ After semantic extraction, the dispatcher routes DispatchActivity objects to app ## Related - Implementation: `vultron/behavior_dispatcher.py` -- Implementation: `vultron/semantic_handler_map.py` +- Implementation: `vultron/api/v2/backend/handler_map.py` (`SEMANTICS_HANDLERS` registry) - Tests: `test/api/v2/backend/test_dispatch_routing.py` - Related Spec: [semantic-extraction.md](semantic-extraction.md) - Related Spec: [handler-protocol.md](handler-protocol.md) diff --git a/specs/handler-protocol.md b/specs/handler-protocol.md index 6786c486..7780c874 100644 --- a/specs/handler-protocol.md +++ b/specs/handler-protocol.md @@ -2,7 +2,7 @@ ## Overview -Handler functions process DispatchActivity objects and implement protocol business logic. All handlers follow a common contract defined by the HandlerProtocol and enforced by the verify_semantics decorator. +Handler functions process DispatchEvent objects and implement protocol business logic. All handlers follow a common contract defined by the HandlerProtocol and enforced by the verify_semantics decorator. **Source**: Protocol design, dispatcher architecture @@ -17,7 +17,7 @@ Handler functions process DispatchActivity objects and implement protocol busine ## Handler Signature (MUST) -- `HP-01-001` All handler functions MUST accept a single DispatchActivity parameter +- `HP-01-001` All handler functions MUST accept a single DispatchEvent parameter - `HP-01-002` Handler functions MAY return None or HandlerResult ## Semantic Verification (MUST) @@ -110,7 +110,7 @@ Handler functions process DispatchActivity objects and implement protocol busine ### HP-01-001, HP-01-002 Verification -- Unit test: Handler accepts DispatchActivity parameter +- Unit test: Handler accepts DispatchEvent parameter - Unit test: Handler returns None or HandlerResult - Type check: Handler signature matches HandlerProtocol @@ -170,9 +170,9 @@ Handler functions process DispatchActivity objects and implement protocol busine ## Related -- Implementation: `vultron/api/v2/backend/handlers.py` -- Implementation: `vultron/api/v2/backend/behavior_dispatcher.py` -- Implementation: `vultron/api/v2/backend/semantic_handler_map.py` +- Implementation: `vultron/api/v2/backend/handlers/` (handler modules) +- Implementation: `vultron/behavior_dispatcher.py` +- Implementation: `vultron/api/v2/backend/handler_map.py` (`SEMANTICS_HANDLERS` registry) - Tests: `test/api/v2/backend/test_handlers.py` - Related Spec: [dispatch-routing.md](dispatch-routing.md) - Related Spec: [semantic-extraction.md](semantic-extraction.md) diff --git a/specs/message-validation.md b/specs/message-validation.md index 12756c70..ecc47ae8 100644 --- a/specs/message-validation.md +++ b/specs/message-validation.md @@ -109,8 +109,8 @@ The inbox handler validates ActivityStreams 2.0 activities before processing to - **HTTP Protocol**: `specs/http-protocol.md` (Content-Type validation MV-06-001, size limits MV-07-001 consolidated as HTTP-01, HTTP-02) - **Idempotency**: `specs/inbox-endpoint.md` IE-10-001, `specs/handler-protocol.md` HP-07-001 -- **Implementation**: `vultron/api/v2/routers/actors.py` (`parse_activity()`) -- **Implementation**: `vultron/as_vocab/activities/` (Pydantic models) +- **Implementation**: `vultron/wire/as2/parser.py` (`parse_activity()`) +- **Implementation**: `vultron/wire/as2/vocab/activities/` (Pydantic models) - **Tests**: `test/api/v2/routers/test_actors.py` - **Related Spec**: [inbox-endpoint.md](inbox-endpoint.md) - **Related Spec**: [error-handling.md](error-handling.md) diff --git a/specs/project-documentation.md b/specs/project-documentation.md index 22cc9764..52ce7674 100644 --- a/specs/project-documentation.md +++ b/specs/project-documentation.md @@ -163,6 +163,8 @@ history from implementation work. - Quick reference information (belongs in AGENTS.md) - Future planning (belongs in IMPLEMENTATION_PLAN.md) - Coding how-tos (belongs in AGENTS.md) +- Implementation history or 'what was done' summaries from build cycles + (belongs in IMPLEMENTATION_HISTORY.md) **Maintenance**: @@ -194,7 +196,7 @@ history from implementation work. - Detailed debugging history (belongs in IMPLEMENTATION_NOTES.md) - Lessons learned (belongs in IMPLEMENTATION_NOTES.md) - Technical how-tos (belongs in AGENTS.md) -- Completed work details (summarize and move to IMPLEMENTATION_NOTES.md) +- Completed work details (summarize and move to IMPLEMENTATION_HISTORY.md) **Maintenance**: @@ -265,6 +267,7 @@ history from implementation work. **Scope** (MUST contain): - Completed implementation phases with details on what was done, when, and how +- Per-build 'what was done' summaries appended after each implementation cycle - Deferred future work that was deprioritized (with rationale and context) - Commit references where relevant pointing to completed work @@ -282,7 +285,7 @@ history from implementation work. When refactoring documentation: 1. **Status updates** → Move to IMPLEMENTATION_NOTES.md with date -2. **Completed phases** → Move to IMPLEMENTATION_HISTORY.md with date and summary +2. **Completed phases / 'what was done' summaries** → Append to IMPLEMENTATION_HISTORY.md 3. **Lessons learned** → Move to IMPLEMENTATION_NOTES.md under relevant date 4. **Future priorities** → Move to IMPLEMENTATION_PLAN.md 5. **Technical gotchas** → Keep in AGENTS.md Common Pitfalls diff --git a/specs/prototype-shortcuts.md b/specs/prototype-shortcuts.md index 03700d10..04aa653d 100644 --- a/specs/prototype-shortcuts.md +++ b/specs/prototype-shortcuts.md @@ -56,8 +56,8 @@ and Python stack candidates. Vulnerability Categorization) or an equivalent tool that evaluates report content and context to produce an engage/defer decision. - The `PrioritizationPolicy` interface in - `vultron/behaviors/report/policy.py` and the `EvaluateCasePriority` BT - node in `vultron/behaviors/report/nodes.py` provide the hook point for + `vultron/core/behaviors/report/policy.py` and the `EvaluateCasePriority` BT + node in `vultron/core/behaviors/report/nodes.py` provide the hook point for this integration. - Note: RM is a **participant-specific** state machine. Each `CaseParticipant` (actor-in-case wrapper) carries its own RM state in @@ -76,5 +76,31 @@ and Python stack candidates. (`from_activitystreams`, `to_activitystreams`) at the protocol boundary - PROTO-06-001 constrains CM-08-001 - PROTO-06-001 constrains CM-08-002 - - See `notes/domain-model-separation.md` for design rationale, known constraints of the current approach, and recommended migration steps + - See `notes/domain-model-separation.md` for design rationale, known + constraints of the current approach, and recommended migration steps + + **Design Note**: As the hexagonal architecture refactor progresses, the + boundary between AS2 wire types and core domain types is becoming more + concrete. The emerging use-cases-as-core-ports pattern (see + `notes/use-case-behavior-trees.md` and + `notes/architecture-ports-and-adapters.md`) may make full AS2 inheritance + in domain objects untenable sooner than originally anticipated. + If a `core/use_cases/` layer is formalized (post-P60), it will likely + require domain objects free of AS2 inheritance. This shortcut SHOULD be + revisited when stubbing `core/use_cases/` in P60-3. + +## Performance Testing (MAY) + +- `PROTO-07-001` `PROD_ONLY` Performance tests and performance assertions MAY + be skipped or marked as expected failures during the prototype stage + - The project is currently in the "make it work" and "make it work right" + phases; performance optimization is premature + - Existing tests that include performance assertions SHOULD be marked + `@pytest.mark.skip` or `@pytest.mark.xfail` if they risk false failures + without being critical for correctness verification + - All performance requirements in other specs MUST carry the `PROD_ONLY` + tag; do not add new performance requirements without this tag during the + prototype stage + - **Rationale**: Premature performance work distracts from correctness + and architectural clarity; defer until exiting the prototype phase diff --git a/specs/semantic-extraction.md b/specs/semantic-extraction.md index a8597140..62459abb 100644 --- a/specs/semantic-extraction.md +++ b/specs/semantic-extraction.md @@ -92,12 +92,12 @@ The inbox handler extracts semantic meaning from ActivityStreams activities by m ## Related -- Implementation: `vultron/semantic_map.py` -- Implementation: `vultron/activity_patterns.py` +- Implementation: `vultron/wire/as2/extractor.py` (patterns and + `find_matching_semantics`; sole AS2→domain mapping point) - Implementation: `vultron/behavior_dispatcher.py` - Implementation: `vultron/api/v2/data/rehydration.py` (object rehydration) - Implementation: `vultron/api/v2/backend/inbox_handler.py` (rehydration before dispatch) -- Implementation: `vultron/enums.py` +- Implementation: `vultron/core/models/events.py` (`MessageSemantics` enum) - Tests: `test/test_semantic_activity_patterns.py` - Tests: `test/test_semantic_handler_map.py` - Related Spec: [dispatch-routing.md](dispatch-routing.md) diff --git a/specs/testability.md b/specs/testability.md index e5b6e20e..16a5d6ec 100644 --- a/specs/testability.md +++ b/specs/testability.md @@ -100,6 +100,25 @@ The Vultron inbox handler must be thoroughly testable at unit, integration, and - `TB-09-002` Test duplication SHOULD be avoided via fixtures and helpers - `TB-09-003` Tests MUST be refactored along with production code +## Architecture Boundary Tests (SHOULD) + +- `TB-10-001` `PROD_ONLY` Once the `core` and `wire` packages are fully + separated (see `specs/architecture.md` and `notes/architecture-review.md`), + architecture boundary tests SHOULD be added to enforce layer separation rules + - Tests SHOULD verify that `vultron/core/` does not import from + `vultron/wire/` or `vultron/api/` + - Tests SHOULD verify that `vultron/wire/` does not import from + `vultron/api/` + - Implementation: use `pytest` + `ast` or an import-linting tool + (e.g., `import-linter`) to detect boundary violations automatically + - **Rationale**: As the codebase grows, accidental cross-layer imports are + easy to introduce. Automated boundary tests catch violations earlier than + code review and enforce the architectural rules documented in + `notes/architecture-ports-and-adapters.md` + - **Timing**: Add these tests once the P65-x violation remediation series + is complete and all active violations in `notes/architecture-review.md` + are resolved + ## Verification ### TB-01-001, TB-01-002 Verification diff --git a/specs/triggerable-behaviors.md b/specs/triggerable-behaviors.md index ecb9dcb2..fea91c11 100644 --- a/specs/triggerable-behaviors.md +++ b/specs/triggerable-behaviors.md @@ -148,7 +148,7 @@ them; a complete implementation requires both reactive and triggerable sides. - The trigger API is the outgoing side; the BT tree is the same regardless of direction (inbound handler vs actor-initiated trigger) - `TRIG-05-002` The trigger endpoint SHOULD invoke the BT tree via the - bridge layer (`vultron/behaviors/bridge.py`) using the same pattern + bridge layer (`vultron/core/behaviors/bridge.py`) using the same pattern as existing BT-using handlers - TRIG-05-002 depends-on BT-05-001 @@ -212,7 +212,7 @@ them; a complete implementation requires both reactive and triggerable sides. ### TRIG-05-001, TRIG-05-002 Verification - Code review: Trigger implementations call existing BT trees via - `vultron/behaviors/bridge.py` + `vultron/core/behaviors/bridge.py` - Unit test: BT execution result is reflected in response activity ### TRIG-06-001, TRIG-06-002 Verification diff --git a/test/api/test_reporting_workflow.py b/test/api/test_reporting_workflow.py index fe6b9ccf..400b5e7e 100644 --- a/test/api/test_reporting_workflow.py +++ b/test/api/test_reporting_workflow.py @@ -19,8 +19,8 @@ import pytest from vultron.api.v2.backend import handlers as h -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Create, as_Offer, as_Read, @@ -28,13 +28,14 @@ as_Reject, as_Accept, ) -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.as_vocab.type_helpers import AsActivityType -from vultron.enums import MessageSemantics -from vultron.semantic_map import find_matching_semantics -from vultron.types import BehaviorHandler, DispatchActivity +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.wire.as2.vocab.type_helpers import AsActivityType +from vultron.core.models.events import MessageSemantics +from vultron.types import BehaviorHandler, DispatchEvent # Fixtures @@ -67,29 +68,30 @@ def case(report): @pytest.fixture def dl(): - # Use default file-based storage for tests so handlers use the same instance - # (handlers call get_datalayer() without arguments) - dl = get_datalayer() - dl.clear_all() # Clear before use to ensure clean state + dl = TinyDbDataLayer(db_path=None) yield dl dl.clear_all() def _call_handler( - activity: AsActivityType, handler: BehaviorHandler, actor=None + activity: AsActivityType, handler: BehaviorHandler, actor=None, dl=None ): + from vultron.wire.as2.extractor import extract_intent + from vultron.types import DispatchEvent - semantics = find_matching_semantics(activity) + event = extract_intent(activity) - assert semantics != MessageSemantics.UNKNOWN - assert semantics in MessageSemantics + assert event.semantic_type != MessageSemantics.UNKNOWN + assert event.semantic_type in MessageSemantics - dispatchable = DispatchActivity( - semantic_type=semantics, activity_id=activity.as_id, payload=activity + dispatchable = DispatchEvent( + semantic_type=event.semantic_type, + activity_id=activity.as_id, + payload=event, ) try: - result = handler(dispatchable=dispatchable) + result = handler(dispatchable=dispatchable, dl=dl) except Exception as e: pytest.fail(f"Handler raised an exception: {e}") assert result is None @@ -99,51 +101,51 @@ def _call_handler( # Tests -def test_create_report_handler_returns_none(reporter, report): +def test_create_report_handler_returns_none(reporter, report, dl): activity = as_Create(actor=reporter, object=report) - _call_handler(activity, h.create_report) + _call_handler(activity, h.create_report, dl=dl) def test_submit_report_persists_activity_and_report(reporter, report, dl): activity = as_Offer(actor=reporter, object=report) - _call_handler(activity, h.submit_report) + _call_handler(activity, h.submit_report, dl=dl) # check side effects assert dl.read(activity.as_id) is not None assert dl.read(report.as_id) is not None -def test_read_activity_handler_noop_returns_none(reporter, report): +def test_read_activity_handler_noop_returns_none(reporter, report, dl): activity = as_Read( actor=reporter, object=as_Offer(actor=reporter, object=report) ) - _call_handler(activity, h.ack_report) # No read handler yet + _call_handler(activity, h.ack_report, dl=dl) -def test_accept_offer(reporter, report): +def test_accept_offer(reporter, report, dl): offer = as_Offer(actor=reporter, object=report) activity = as_Accept(actor=reporter, object=offer) - _call_handler(activity, h.validate_report) + _call_handler(activity, h.validate_report, dl=dl) def test_tentative_reject_triggers_invalidation(reporter, report, dl): offer = as_Offer(actor=reporter, object=report) activity = as_TentativeReject(actor=reporter, object=offer) - _call_handler(activity, h.invalidate_report) + _call_handler(activity, h.invalidate_report, dl=dl) # check side effects assert dl.read(activity.as_id) is not None -def test_create_case_handler_returns_none(coordinator, case): +def test_create_case_handler_returns_none(coordinator, case, dl): activity = as_Create(actor=coordinator, object=case) - _call_handler(activity, h.create_case, coordinator) + _call_handler(activity, h.create_case, coordinator, dl=dl) def test_reject_offer_triggers_close_report(reporter, report, dl): offer = as_Offer(actor=reporter, object=report) activity = as_Reject(actor=reporter, object=offer) - _call_handler(activity, h.close_report) + _call_handler(activity, h.close_report, dl=dl) # check side effects assert dl.read(activity.as_id) is not None diff --git a/test/api/v2/backend/test_handlers.py b/test/api/v2/backend/test_handlers.py index 7f5300b0..eff70631 100644 --- a/test/api/v2/backend/test_handlers.py +++ b/test/api/v2/backend/test_handlers.py @@ -17,11 +17,41 @@ VultronApiHandlerMissingSemanticError, VultronApiHandlerSemanticMismatchError, ) -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import ( + MessageSemantics, + VultronEvent, +) +from vultron.types import DispatchEvent +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) + + +def _make_payload(activity, **extra_fields) -> VultronEvent: + """Wrap an AS2 activity in the appropriate typed VultronEvent for use in tests. + + Delegates to ``extract_intent()`` so that domain object fields (``activity``, + ``report``, ``case``, etc.) are populated exactly as they would be in production. + ``extra_fields`` can override any field (e.g. ``semantic_type``) after extraction. + """ + from vultron.wire.as2.extractor import extract_intent + + event = extract_intent(activity) + if extra_fields: + return event.model_copy(update=extra_fields) + return event + + +def _make_dispatchable(activity, semantic_type, **payload_overrides): + """Create a DispatchEvent from an AS2 activity.""" + payload = _make_payload(activity, **payload_overrides) + return DispatchEvent( + semantic_type=semantic_type, + activity_id=activity.as_id, + payload=payload, + ) class TestVerifySemanticsDecorator: @@ -32,11 +62,11 @@ def test_decorator_validates_matching_semantics(self): # Create a test handler decorated with verify_semantics @handlers.verify_semantics(MessageSemantics.CREATE_REPORT) - def test_handler(dispatchable: DispatchActivity) -> str: + def test_handler(dispatchable: DispatchEvent, dl=None) -> str: return "success" - # Create a mock DispatchActivity with matching semantics - mock_activity = MagicMock(spec=DispatchActivity) + # Create a mock DispatchEvent with matching semantics + mock_activity = MagicMock(spec=DispatchEvent) mock_activity.semantic_type = MessageSemantics.CREATE_REPORT # Create proper as_Create activity with VulnerabilityReport object @@ -46,56 +76,47 @@ def test_handler(dispatchable: DispatchActivity) -> str: create_activity = as_Create( actor="https://example.org/users/tester", object=report ) - mock_activity.payload = create_activity + mock_activity.payload = _make_payload(create_activity) # Should execute successfully - result = test_handler(mock_activity) + result = test_handler(mock_activity, None) assert result == "success" def test_decorator_raises_error_for_missing_semantic_type(self): """Test that decorator raises error when semantic_type is None.""" @handlers.verify_semantics(MessageSemantics.CREATE_REPORT) - def test_handler(dispatchable: DispatchActivity) -> str: + def test_handler(dispatchable: DispatchEvent, dl=None) -> str: return "success" # Create mock with None semantic_type - mock_activity = MagicMock(spec=DispatchActivity) + mock_activity = MagicMock(spec=DispatchEvent) mock_activity.semantic_type = None # Should raise VultronApiHandlerMissingSemanticError with pytest.raises(VultronApiHandlerMissingSemanticError): - test_handler(mock_activity) + test_handler(mock_activity, None) def test_decorator_raises_error_for_semantic_mismatch(self): """Test that decorator raises error when semantic types don't match.""" @handlers.verify_semantics(MessageSemantics.CREATE_REPORT) - def test_handler(dispatchable: DispatchActivity) -> str: + def test_handler(dispatchable: DispatchEvent, dl=None) -> str: return "success" - # Create mock that claims CREATE_REPORT but payload says CREATE_CASE - mock_activity = MagicMock(spec=DispatchActivity) - mock_activity.semantic_type = MessageSemantics.CREATE_REPORT - - # Create proper as_Create activity with VulnerabilityCase object (mismatched!) - case = VulnerabilityCase( - name="TEST-CASE-001", content="Test vulnerability case" - ) - create_case_activity = as_Create( - actor="https://example.org/users/tester", object=case - ) - mock_activity.payload = create_case_activity + # Create mock with wrong semantic_type (handler expects CREATE_REPORT) + mock_activity = MagicMock(spec=DispatchEvent) + mock_activity.semantic_type = MessageSemantics.CREATE_CASE # Should raise VultronApiHandlerSemanticMismatchError with pytest.raises(VultronApiHandlerSemanticMismatchError): - test_handler(mock_activity) + test_handler(mock_activity, None) def test_decorator_preserves_function_name(self): """Test that decorator preserves the wrapped function's __name__.""" @handlers.verify_semantics(MessageSemantics.CREATE_REPORT) - def test_handler(dispatchable: DispatchActivity) -> str: + def test_handler(dispatchable: DispatchEvent, dl=None) -> str: return "success" # Decorator should preserve function name via @wraps @@ -162,9 +183,6 @@ class TestHandlerExecution: def test_create_report_executes_with_valid_semantics(self): """Test create_report handler executes when semantics match.""" - mock_activity = MagicMock(spec=DispatchActivity) - mock_activity.semantic_type = MessageSemantics.CREATE_REPORT - # Create proper as_Create activity with VulnerabilityReport object report = VulnerabilityReport( name="TEST-002", content="Test vulnerability report" @@ -172,18 +190,18 @@ def test_create_report_executes_with_valid_semantics(self): create_activity = as_Create( actor="https://example.org/users/tester", object=report ) - mock_activity.payload = create_activity + dispatchable = _make_dispatchable( + create_activity, MessageSemantics.CREATE_REPORT + ) # Should execute without raising - result = handlers.create_report(mock_activity) + mock_dl = MagicMock() + result = handlers.create_report(dispatchable, mock_dl) # Current stub implementation returns None assert result is None def test_create_case_executes_with_valid_semantics(self): """Test create_case handler executes when semantics match.""" - mock_activity = MagicMock(spec=DispatchActivity) - mock_activity.semantic_type = MessageSemantics.CREATE_CASE - # Create proper as_Create activity with VulnerabilityCase object case = VulnerabilityCase( name="TEST-CASE-002", content="Test vulnerability case" @@ -191,30 +209,23 @@ def test_create_case_executes_with_valid_semantics(self): create_activity = as_Create( actor="https://example.org/users/tester", object=case ) - mock_activity.payload = create_activity + dispatchable = _make_dispatchable( + create_activity, MessageSemantics.CREATE_CASE + ) # Should execute without raising - result = handlers.create_case(mock_activity) + mock_dl = MagicMock() + result = handlers.create_case(dispatchable, mock_dl) assert result is None def test_handler_rejects_wrong_semantic_type(self): """Test handler rejects activity with wrong semantic type.""" - mock_activity = MagicMock(spec=DispatchActivity) - mock_activity.semantic_type = MessageSemantics.CREATE_REPORT - - # Create proper as_Create activity with VulnerabilityCase object - # Payload says CREATE_CASE, but handler expects CREATE_REPORT - case = VulnerabilityCase( - name="TEST-CASE-003", content="Test vulnerability case" - ) - create_activity = as_Create( - actor="https://example.org/users/tester", object=case - ) - mock_activity.payload = create_activity + mock_activity = MagicMock(spec=DispatchEvent) + mock_activity.semantic_type = MessageSemantics.CREATE_CASE - # Should raise semantic mismatch error + # Should raise semantic mismatch error (handler expects CREATE_REPORT) with pytest.raises(VultronApiHandlerSemanticMismatchError): - handlers.create_report(mock_activity) + handlers.create_report(mock_activity, None) class TestInviteActorHandlers: @@ -223,102 +234,98 @@ class TestInviteActorHandlers: def test_invite_actor_to_case_stores_invite(self, monkeypatch): """invite_actor_to_case persists the Invite activity to the DataLayer.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import RmInviteToCase + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + RmInviteToCaseActivity, + ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( id="https://example.org/cases/case1/invitations/1", actor="https://example.org/users/owner", object="https://example.org/users/coordinator", target="https://example.org/cases/case1", ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.INVITE_ACTOR_TO_CASE - mock_dispatchable.payload = invite + dispatchable = _make_dispatchable( + invite, MessageSemantics.INVITE_ACTOR_TO_CASE + ) - handlers.invite_actor_to_case(mock_dispatchable) + handlers.invite_actor_to_case(dispatchable, dl) stored = dl.get(invite.as_type.value, invite.as_id) assert stored is not None def test_invite_actor_to_case_idempotent(self, monkeypatch): """invite_actor_to_case skips storing a duplicate Invite.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import RmInviteToCase + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + RmInviteToCaseActivity, + ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( id="https://example.org/cases/case1/invitations/2", actor="https://example.org/users/owner", object="https://example.org/users/coordinator", target="https://example.org/cases/case1", ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.INVITE_ACTOR_TO_CASE - mock_dispatchable.payload = invite + dispatchable = _make_dispatchable( + invite, MessageSemantics.INVITE_ACTOR_TO_CASE + ) - handlers.invite_actor_to_case(mock_dispatchable) - handlers.invite_actor_to_case( - mock_dispatchable - ) # second call is no-op + handlers.invite_actor_to_case(dispatchable, dl) + handlers.invite_actor_to_case(dispatchable, dl) # second call is no-op stored = dl.get(invite.as_type.value, invite.as_id) assert stored is not None def test_reject_invite_actor_to_case_logs_rejection(self): """reject_invite_actor_to_case logs without raising.""" - from vultron.as_vocab.activities.case import ( - RmInviteToCase, - RmRejectInviteToCase, + from vultron.wire.as2.vocab.activities.case import ( + RmInviteToCaseActivity, + RmRejectInviteToCaseActivity, ) - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( id="https://example.org/cases/case1/invitations/3", actor="https://example.org/users/owner", object="https://example.org/users/coordinator", target="https://example.org/cases/case1", ) - reject = RmRejectInviteToCase( + reject = RmRejectInviteToCaseActivity( actor="https://example.org/users/coordinator", object=invite, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE + dispatchable = _make_dispatchable( + reject, MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE ) - mock_dispatchable.payload = reject - result = handlers.reject_invite_actor_to_case(mock_dispatchable) + result = handlers.reject_invite_actor_to_case( + dispatchable, MagicMock() + ) assert result is None def test_remove_case_participant_from_case(self, monkeypatch): """remove_case_participant_from_case removes the participant from case.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Remove, ) - from vultron.as_vocab.objects.case_participant import CaseParticipant - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", + "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, ) @@ -342,14 +349,13 @@ def test_remove_case_participant_from_case(self, monkeypatch): target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE + dispatchable = _make_dispatchable( + remove_activity, MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE ) - mock_dispatchable.payload = remove_activity - handlers.remove_case_participant_from_case(mock_dispatchable) + handlers.remove_case_participant_from_case(dispatchable, dl) + case = dl.read(case.as_id) assert participant.as_id not in [ (p.as_id if hasattr(p, "as_id") else p) for p in case.case_participants @@ -357,20 +363,18 @@ def test_remove_case_participant_from_case(self, monkeypatch): def test_remove_case_participant_idempotent(self, monkeypatch): """remove_case_participant_from_case is idempotent when participant absent.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Remove, ) - from vultron.as_vocab.objects.case_participant import CaseParticipant - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) case = VulnerabilityCase( id="https://example.org/cases/case3", @@ -391,35 +395,286 @@ def test_remove_case_participant_idempotent(self, monkeypatch): target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE + dispatchable = _make_dispatchable( + remove_activity, MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE ) - mock_dispatchable.payload = remove_activity - result = handlers.remove_case_participant_from_case(mock_dispatchable) + result = handlers.remove_case_participant_from_case(dispatchable, dl) assert result is None + def test_add_case_participant_updates_index(self, monkeypatch): + """add_case_participant_to_case updates actor_participant_index (SC-PRE-2).""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Add, + ) + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + actor_id = "https://example.org/users/coordinator" + case = VulnerabilityCase( + id="https://example.org/cases/caseAP1", + name="TEST-ADD-INDEX", + ) + participant = CaseParticipant( + id="https://example.org/cases/caseAP1/participants/coord", + attributed_to=actor_id, + context=case.as_id, + ) + dl.create(case) + dl.create(participant) + + add_activity = as_Add( + actor="https://example.org/users/owner", + object=participant, + target=case, + ) + + dispatchable = _make_dispatchable( + add_activity, MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE + ) + + handlers.add_case_participant_to_case(dispatchable, dl) + + case = dl.read(case.as_id) + assert actor_id in case.actor_participant_index + assert case.actor_participant_index[actor_id] == participant.as_id + + def test_remove_case_participant_clears_index(self, monkeypatch): + """remove_case_participant_from_case clears actor_participant_index (SC-PRE-2).""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Remove, + ) + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + actor_id = "https://example.org/users/coordinator" + case = VulnerabilityCase( + id="https://example.org/cases/caseRM1", + name="TEST-REMOVE-INDEX", + ) + participant = CaseParticipant( + id="https://example.org/cases/caseRM1/participants/coord", + attributed_to=actor_id, + context=case.as_id, + ) + case.add_participant(participant) + dl.create(case) + dl.create(participant) + + assert actor_id in case.actor_participant_index + + remove_activity = as_Remove( + actor="https://example.org/users/owner", + object=participant, + target=case, + ) + + dispatchable = _make_dispatchable( + remove_activity, MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE + ) + + handlers.remove_case_participant_from_case(dispatchable, dl) + + case = dl.read(case.as_id) + assert actor_id not in case.actor_participant_index + + def test_accept_invite_actor_to_case_adds_participant(self, monkeypatch): + """accept_invite_actor_to_case creates a CaseParticipant and adds them to the case.""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, + ) + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + invitee_id = "https://example.org/users/coordinator" + invitee = as_Actor(id=invitee_id) + case = VulnerabilityCase( + id="https://example.org/cases/caseIA1", + name="TEST-ACCEPT-INVITE", + ) + invite = RmInviteToCaseActivity( + id="https://example.org/cases/caseIA1/invitations/1", + actor="https://example.org/users/owner", + object=invitee, + target=case, + ) + dl.create(case) + dl.create(invite) + + accept = RmAcceptInviteToCaseActivity( + actor=invitee_id, + object=invite, + ) + + dispatchable = _make_dispatchable( + accept, MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE + ) + + handlers.accept_invite_actor_to_case(dispatchable, dl) + + case = dl.read(case.as_id) + assert invitee_id in case.actor_participant_index + + def test_accept_invite_actor_to_case_records_active_embargo( + self, monkeypatch + ): + """accept_invite_actor_to_case records the active embargo ID on the new participant (CM-10-001, CM-10-003).""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, + ) + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + invitee_id = "https://example.org/users/coordinator" + invitee = as_Actor(id=invitee_id) + embargo = EmbargoEvent( + id="https://example.org/cases/caseIA2/embargo_events/e1", + content="Active embargo", + ) + case = VulnerabilityCase( + id="https://example.org/cases/caseIA2", + name="TEST-ACCEPT-INVITE-EMBARGO", + ) + case.active_embargo = embargo.as_id + invite = RmInviteToCaseActivity( + id="https://example.org/cases/caseIA2/invitations/1", + actor="https://example.org/users/owner", + object=invitee, + target=case, + ) + dl.create(case) + dl.create(embargo) + dl.create(invite) + + accept = RmAcceptInviteToCaseActivity( + actor=invitee_id, + object=invite, + ) + + dispatchable = _make_dispatchable( + accept, MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE + ) + + handlers.accept_invite_actor_to_case(dispatchable, dl) + + case = dl.read(case.as_id) + participant_id = case.actor_participant_index.get(invitee_id) + assert participant_id is not None + participant_obj = dl.get(id_=participant_id) + assert participant_obj is not None + assert embargo.as_id in participant_obj.accepted_embargo_ids + + def test_accept_invite_actor_to_case_records_case_event(self, monkeypatch): + """accept_invite_actor_to_case appends a trusted-timestamp event to case.events (CM-02-009).""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, + ) + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + invitee_id = "https://example.org/users/coordinator" + invitee = as_Actor(id=invitee_id) + case = VulnerabilityCase( + id="https://example.org/cases/caseIA3", + name="TEST-ACCEPT-INVITE-EVENT", + ) + invite = RmInviteToCaseActivity( + id="https://example.org/cases/caseIA3/invitations/1", + actor="https://example.org/users/owner", + object=invitee, + target=case, + ) + dl.create(case) + dl.create(invite) + + accept = RmAcceptInviteToCaseActivity( + actor=invitee_id, + object=invite, + ) + + dispatchable = _make_dispatchable( + accept, MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE + ) + + assert len(case.events) == 0 + + handlers.accept_invite_actor_to_case(dispatchable, dl) + + case = dl.read(case.as_id) + assert len(case.events) >= 1 + event_types = [e.event_type for e in case.events] + assert "participant_joined" in event_types + class TestEmbargoHandlers: """Tests for embargo management handlers.""" def test_create_embargo_event_stores_event(self, monkeypatch): """create_embargo_event persists the EmbargoEvent to the DataLayer.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Create, ) - from vultron.as_vocab.objects.embargo_event import EmbargoEvent - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) case = VulnerabilityCase( id="https://example.org/cases/case_cem1", @@ -435,31 +690,27 @@ def test_create_embargo_event_stores_event(self, monkeypatch): context=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.CREATE_EMBARGO_EVENT - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_EMBARGO_EVENT + ) - handlers.create_embargo_event(mock_dispatchable) + handlers.create_embargo_event(dispatchable, dl) stored = dl.get(embargo.as_type.value, embargo.as_id) assert stored is not None def test_create_embargo_event_idempotent(self, monkeypatch): """create_embargo_event skips storing a duplicate EmbargoEvent.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Create, ) - from vultron.as_vocab.objects.embargo_event import EmbargoEvent - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) case = VulnerabilityCase( id="https://example.org/cases/case_cem2", @@ -474,29 +725,31 @@ def test_create_embargo_event_idempotent(self, monkeypatch): object=embargo, context=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.CREATE_EMBARGO_EVENT - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_EMBARGO_EVENT + ) - handlers.create_embargo_event(mock_dispatchable) - handlers.create_embargo_event(mock_dispatchable) # second call no-op + handlers.create_embargo_event(dispatchable, dl) + handlers.create_embargo_event(dispatchable, dl) # second call no-op stored = dl.get(embargo.as_type.value, embargo.as_id) assert stored is not None def test_add_embargo_event_to_case_activates_embargo(self, monkeypatch): """add_embargo_event_to_case sets the active embargo on the case.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.embargo import AddEmbargoToCase - from vultron.as_vocab.objects.embargo_event import EmbargoEvent - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.embargo import ( + AddEmbargoToCaseActivity, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) from vultron.bt.embargo_management.states import EM dl = TinyDbDataLayer(db_path=None) monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", + "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, ) @@ -511,52 +764,47 @@ def test_add_embargo_event_to_case_activates_embargo(self, monkeypatch): dl.create(case) dl.create(embargo) - activity = AddEmbargoToCase( + activity = AddEmbargoToCaseActivity( actor="https://example.org/users/vendor", object=embargo, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE ) - mock_dispatchable.payload = activity - handlers.add_embargo_event_to_case(mock_dispatchable) + handlers.add_embargo_event_to_case(dispatchable, dl) + case = dl.read(case.as_id) assert case.active_embargo is not None assert case.current_status.em_state == EM.ACTIVE def test_invite_to_embargo_on_case_stores_proposal(self, monkeypatch): - """invite_to_embargo_on_case persists the EmProposeEmbargo activity.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.embargo import EmProposeEmbargo - from vultron.as_vocab.objects.embargo_event import EmbargoEvent + """invite_to_embargo_on_case persists the EmProposeEmbargoActivity activity.""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.embargo import ( + EmProposeEmbargoActivity, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) embargo = EmbargoEvent( id="https://example.org/cases/case_em2/embargo_events/e2", content="Proposed embargo", ) - proposal = EmProposeEmbargo( + proposal = EmProposeEmbargoActivity( id="https://example.org/cases/case_em2/embargo_proposals/1", actor="https://example.org/users/vendor", object=embargo, context="https://example.org/cases/case_em2", ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.INVITE_TO_EMBARGO_ON_CASE + dispatchable = _make_dispatchable( + proposal, MessageSemantics.INVITE_TO_EMBARGO_ON_CASE ) - mock_dispatchable.payload = proposal - handlers.invite_to_embargo_on_case(mock_dispatchable) + handlers.invite_to_embargo_on_case(dispatchable, dl) stored = dl.get(proposal.as_type.value, proposal.as_id) assert stored is not None @@ -565,22 +813,18 @@ def test_accept_invite_to_embargo_on_case_activates_embargo( self, monkeypatch ): """accept_invite_to_embargo_on_case activates the embargo on the case.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.embargo import ( - EmAcceptEmbargo, - EmProposeEmbargo, + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.embargo import ( + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, ) - from vultron.as_vocab.objects.embargo_event import EmbargoEvent - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) from vultron.bt.embargo_management.states import EM dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -595,7 +839,7 @@ def test_accept_invite_to_embargo_on_case_activates_embargo( content="Embargo", ) # Use inline objects (not string IDs) so rehydration skips DataLayer lookup - proposal = EmProposeEmbargo( + proposal = EmProposeEmbargoActivity( id="https://example.org/cases/case_em3/embargo_proposals/1", actor="https://example.org/users/vendor", object=embargo, @@ -605,53 +849,170 @@ def test_accept_invite_to_embargo_on_case_activates_embargo( dl.create(embargo) dl.create(proposal) - accept = EmAcceptEmbargo( + accept = EmAcceptEmbargoActivity( actor="https://example.org/users/coordinator", object=proposal, context=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE + dispatchable = _make_dispatchable( + accept, MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE ) - mock_dispatchable.payload = accept - handlers.accept_invite_to_embargo_on_case(mock_dispatchable) + handlers.accept_invite_to_embargo_on_case(dispatchable, dl) + case = dl.read(case.as_id) assert case.active_embargo is not None assert case.current_status.em_state == EM.ACTIVE + def test_accept_invite_to_embargo_records_embargo_on_participant( + self, monkeypatch + ): + """accept_invite_to_embargo_on_case records embargo ID in participant.accepted_embargo_ids (CM-10-002, CM-10-003).""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.embargo import ( + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, + ) + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + coordinator_id = "https://example.org/users/coordinator" + case = VulnerabilityCase( + id="https://example.org/cases/case_em5", + name="EM Accept Participant Test", + ) + embargo = EmbargoEvent( + id="https://example.org/cases/case_em5/embargo_events/e5", + content="Embargo", + ) + participant = CaseParticipant( + id="https://example.org/cases/case_em5/participants/coord", + attributed_to=coordinator_id, + context=case.as_id, + ) + case.add_participant(participant) + proposal = EmProposeEmbargoActivity( + id="https://example.org/cases/case_em5/embargo_proposals/1", + actor="https://example.org/users/vendor", + object=embargo, + context=case, + ) + dl.create(case) + dl.create(embargo) + dl.create(participant) + dl.create(proposal) + + accept = EmAcceptEmbargoActivity( + actor=coordinator_id, + object=proposal, + context=case, + ) + dispatchable = _make_dispatchable( + accept, MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE + ) + + handlers.accept_invite_to_embargo_on_case(dispatchable, dl) + + updated_participant = dl.get(id_=participant.as_id) + assert updated_participant is not None + assert embargo.as_id in updated_participant.accepted_embargo_ids + + def test_accept_invite_to_embargo_records_case_event(self, monkeypatch): + """accept_invite_to_embargo_on_case appends a trusted-timestamp event to case.events (CM-02-009).""" + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.embargo import ( + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + case = VulnerabilityCase( + id="https://example.org/cases/case_em6", + name="EM Accept Event Test", + ) + embargo = EmbargoEvent( + id="https://example.org/cases/case_em6/embargo_events/e6", + content="Embargo", + ) + proposal = EmProposeEmbargoActivity( + id="https://example.org/cases/case_em6/embargo_proposals/1", + actor="https://example.org/users/vendor", + object=embargo, + context=case, + ) + dl.create(case) + dl.create(embargo) + dl.create(proposal) + + accept = EmAcceptEmbargoActivity( + actor="https://example.org/users/coordinator", + object=proposal, + context=case, + ) + dispatchable = _make_dispatchable( + accept, MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE + ) + + assert len(case.events) == 0 + + handlers.accept_invite_to_embargo_on_case(dispatchable, dl) + + case = dl.read(case.as_id) + assert len(case.events) >= 1 + event_types = [e.event_type for e in case.events] + assert "embargo_accepted" in event_types + def test_reject_invite_to_embargo_on_case_logs_rejection(self): """reject_invite_to_embargo_on_case logs without raising.""" - from vultron.as_vocab.activities.embargo import ( - EmProposeEmbargo, - EmRejectEmbargo, + from vultron.wire.as2.vocab.activities.embargo import ( + EmProposeEmbargoActivity, + EmRejectEmbargoActivity, ) - from vultron.as_vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent embargo = EmbargoEvent( id="https://example.org/cases/case_em4/embargo_events/e4", content="Embargo", ) - proposal = EmProposeEmbargo( + proposal = EmProposeEmbargoActivity( id="https://example.org/cases/case_em4/embargo_proposals/1", actor="https://example.org/users/vendor", object=embargo, context="https://example.org/cases/case_em4", ) - reject = EmRejectEmbargo( + reject = EmRejectEmbargoActivity( actor="https://example.org/users/coordinator", object=proposal, context="https://example.org/cases/case_em4", ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE + dispatchable = _make_dispatchable( + reject, MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE ) - mock_dispatchable.payload = reject - result = handlers.reject_invite_to_embargo_on_case(mock_dispatchable) + result = handlers.reject_invite_to_embargo_on_case( + dispatchable, MagicMock() + ) assert result is None @@ -660,17 +1021,13 @@ class TestNoteHandlers: def test_create_note_stores_note(self, monkeypatch): """create_note persists the Note to the DataLayer.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Create, ) - from vultron.as_vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.base.objects.object_types import as_Note dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) note = as_Note( id="https://example.org/notes/note1", @@ -681,28 +1038,24 @@ def test_create_note_stores_note(self, monkeypatch): object=note, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.CREATE_NOTE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_NOTE + ) - handlers.create_note(mock_dispatchable) + handlers.create_note(dispatchable, dl) stored = dl.get(note.as_type.value, note.as_id) assert stored is not None def test_create_note_idempotent(self, monkeypatch): """create_note skips storing a duplicate Note.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Create, ) - from vultron.as_vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.base.objects.object_types import as_Note dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) note = as_Note( id="https://example.org/notes/note2", @@ -712,30 +1065,28 @@ def test_create_note_idempotent(self, monkeypatch): actor="https://example.org/users/finder", object=note, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.CREATE_NOTE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_NOTE + ) dl.create(note) - handlers.create_note(mock_dispatchable) + handlers.create_note(dispatchable, dl) stored = dl.get(note.as_type.value, note.as_id) assert stored is not None def test_add_note_to_case_appends_note(self, monkeypatch): """add_note_to_case appends note ID to case.notes and persists.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import AddNoteToCase - from vultron.as_vocab.base.objects.object_types import as_Note - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + AddNoteToCaseActivity, + ) + from vultron.wire.as2.vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -752,33 +1103,32 @@ def test_add_note_to_case_appends_note(self, monkeypatch): dl.create(case) dl.create(note) - activity = AddNoteToCase( + activity = AddNoteToCaseActivity( actor="https://example.org/users/finder", object=note, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.ADD_NOTE_TO_CASE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.ADD_NOTE_TO_CASE + ) - handlers.add_note_to_case(mock_dispatchable) + handlers.add_note_to_case(dispatchable, dl) + case = dl.read(case.as_id) assert note.as_id in case.notes def test_add_note_to_case_idempotent(self, monkeypatch): """add_note_to_case skips adding a note already in the case.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import AddNoteToCase - from vultron.as_vocab.base.objects.object_types import as_Note - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + AddNoteToCaseActivity, + ) + from vultron.wire.as2.vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -796,35 +1146,31 @@ def test_add_note_to_case_idempotent(self, monkeypatch): dl.create(case) dl.create(note) - activity = AddNoteToCase( + activity = AddNoteToCaseActivity( actor="https://example.org/users/finder", object=note, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.ADD_NOTE_TO_CASE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.ADD_NOTE_TO_CASE + ) - handlers.add_note_to_case(mock_dispatchable) + handlers.add_note_to_case(dispatchable, dl) assert case.notes.count(note.as_id) == 1 def test_remove_note_from_case_removes_note(self, monkeypatch): """remove_note_from_case removes note ID from case.notes and persists.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Remove, ) - from vultron.as_vocab.base.objects.object_types import as_Note - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -847,32 +1193,27 @@ def test_remove_note_from_case_removes_note(self, monkeypatch): object=note, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REMOVE_NOTE_FROM_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.REMOVE_NOTE_FROM_CASE ) - mock_dispatchable.payload = activity - handlers.remove_note_from_case(mock_dispatchable) + handlers.remove_note_from_case(dispatchable, dl) + case = dl.read(case.as_id) assert note.as_id not in case.notes def test_remove_note_from_case_idempotent(self, monkeypatch): """remove_note_from_case is idempotent when note not in case.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.base.objects.activities.transitive import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Remove, ) - from vultron.as_vocab.base.objects.object_types import as_Note - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -894,13 +1235,11 @@ def test_remove_note_from_case_idempotent(self, monkeypatch): object=note, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REMOVE_NOTE_FROM_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.REMOVE_NOTE_FROM_CASE ) - mock_dispatchable.payload = activity - result = handlers.remove_note_from_case(mock_dispatchable) + result = handlers.remove_note_from_case(dispatchable, dl) assert result is None @@ -909,18 +1248,16 @@ class TestStatusHandlers: def test_create_case_status_stores_status(self, monkeypatch): """create_case_status persists the CaseStatus to the DataLayer.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import CreateCaseStatus - from vultron.as_vocab.objects.case_status import CaseStatus - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + CreateCaseStatusActivity, + ) + from vultron.wire.as2.vocab.objects.case_status import CaseStatus + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) case = VulnerabilityCase( id="https://example.org/cases/case_cs1", @@ -930,35 +1267,33 @@ def test_create_case_status_stores_status(self, monkeypatch): id="https://example.org/cases/case_cs1/statuses/s1", context=case.as_id, ) - activity = CreateCaseStatus( + activity = CreateCaseStatusActivity( actor="https://example.org/users/vendor", object=status, context=case.as_id, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.CREATE_CASE_STATUS - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_CASE_STATUS + ) - handlers.create_case_status(mock_dispatchable) + handlers.create_case_status(dispatchable, dl) stored = dl.get(status.as_type.value, status.as_id) assert stored is not None def test_create_case_status_idempotent(self, monkeypatch): """create_case_status skips storing a duplicate CaseStatus.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import CreateCaseStatus - from vultron.as_vocab.objects.case_status import CaseStatus - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + CreateCaseStatusActivity, + ) + from vultron.wire.as2.vocab.objects.case_status import CaseStatus + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) case = VulnerabilityCase( id="https://example.org/cases/case_cs2", @@ -970,34 +1305,32 @@ def test_create_case_status_idempotent(self, monkeypatch): ) dl.create(status) - activity = CreateCaseStatus( + activity = CreateCaseStatusActivity( actor="https://example.org/users/vendor", object=status, context=case.as_id, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.CREATE_CASE_STATUS - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_CASE_STATUS + ) - handlers.create_case_status(mock_dispatchable) + handlers.create_case_status(dispatchable, dl) stored = dl.get(status.as_type.value, status.as_id) assert stored is not None def test_add_case_status_to_case_appends_status(self, monkeypatch): """add_case_status_to_case appends status ID to case.case_statuses.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import AddStatusToCase - from vultron.as_vocab.objects.case_status import CaseStatus - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + AddStatusToCaseActivity, + ) + from vultron.wire.as2.vocab.objects.case_status import CaseStatus + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -1014,19 +1347,18 @@ def test_add_case_status_to_case_appends_status(self, monkeypatch): dl.create(case) dl.create(status) - activity = AddStatusToCase( + activity = AddStatusToCaseActivity( actor="https://example.org/users/vendor", object=status, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.ADD_CASE_STATUS_TO_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.ADD_CASE_STATUS_TO_CASE ) - mock_dispatchable.payload = activity - handlers.add_case_status_to_case(mock_dispatchable) + handlers.add_case_status_to_case(dispatchable, dl) + case = dl.read(case.as_id) status_ids = [ (s.as_id if hasattr(s, "as_id") else s) for s in case.case_statuses ] @@ -1034,23 +1366,21 @@ def test_add_case_status_to_case_appends_status(self, monkeypatch): def test_create_participant_status_stores_status(self, monkeypatch): """create_participant_status persists the ParticipantStatus.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case_participant import ( - CreateStatusForParticipant, + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case_participant import ( + CreateStatusForParticipantActivity, + ) + from vultron.wire.as2.vocab.objects.case_status import ( + ParticipantStatus, ) - from vultron.as_vocab.objects.case_status import ParticipantStatus dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) pstatus = ParticipantStatus( id="https://example.org/cases/case_ps1/participants/p1/statuses/s1", context="https://example.org/cases/case_ps1", ) - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) @@ -1058,19 +1388,17 @@ def test_create_participant_status_stores_status(self, monkeypatch): id="https://example.org/cases/case_ps1", name="PS Case 1", ) - activity = CreateStatusForParticipant( + activity = CreateStatusForParticipantActivity( actor="https://example.org/users/vendor", object=pstatus, context=case_ps1, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.CREATE_PARTICIPANT_STATUS + dispatchable = _make_dispatchable( + activity, MessageSemantics.CREATE_PARTICIPANT_STATUS ) - mock_dispatchable.payload = activity - handlers.create_participant_status(mock_dispatchable) + handlers.create_participant_status(dispatchable, dl) stored = dl.get(pstatus.as_type.value, pstatus.as_id) assert stored is not None @@ -1079,18 +1407,18 @@ def test_add_participant_status_to_participant_appends_status( self, monkeypatch ): """add_participant_status_to_participant appends status to participant.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case_participant import ( - AddStatusToParticipant, + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case_participant import ( + AddStatusToParticipantActivity, + ) + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.case_status import ( + ParticipantStatus, ) - from vultron.as_vocab.objects.case_participant import CaseParticipant - from vultron.as_vocab.objects.case_status import ParticipantStatus dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -1105,7 +1433,7 @@ def test_add_participant_status_to_participant_appends_status( id="https://example.org/cases/case_ps2/participants/p2/statuses/s2", context="https://example.org/cases/case_ps2", ) - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) @@ -1116,20 +1444,19 @@ def test_add_participant_status_to_participant_appends_status( dl.create(participant) dl.create(pstatus) - activity = AddStatusToParticipant( + activity = AddStatusToParticipantActivity( actor="https://example.org/users/vendor", object=pstatus, target=participant, context=case_ps2, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT + dispatchable = _make_dispatchable( + activity, MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT ) - mock_dispatchable.payload = activity - handlers.add_participant_status_to_participant(mock_dispatchable) + handlers.add_participant_status_to_participant(dispatchable, dl) + participant = dl.read(participant.as_id) status_ids = [ (s.as_id if hasattr(s, "as_id") else s) for s in participant.participant_statuses @@ -1142,69 +1469,63 @@ class TestSuggestActorHandlers: def test_suggest_actor_to_case_persists_recommendation(self, monkeypatch): """suggest_actor_to_case persists the RecommendActor offer.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.actor import RecommendActor - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Actor - dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, + from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, ) + dl = TinyDbDataLayer(db_path=None) + coordinator = as_Actor(id="https://example.org/users/coordinator") case = VulnerabilityCase( id="https://example.org/cases/case_sa1", name="SA Case 1", ) - activity = RecommendActor( + activity = RecommendActorActivity( actor="https://example.org/users/finder", object=coordinator, target=case, to="https://example.org/users/vendor", ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.SUGGEST_ACTOR_TO_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.SUGGEST_ACTOR_TO_CASE ) - mock_dispatchable.payload = activity - handlers.suggest_actor_to_case(mock_dispatchable) + handlers.suggest_actor_to_case(dispatchable, dl) stored = dl.get(activity.as_type.value, activity.as_id) assert stored is not None def test_suggest_actor_to_case_idempotent(self, monkeypatch): """suggest_actor_to_case is idempotent — second call is a no-op.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.actor import RecommendActor - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, + ) + + from vultron.wire.as2.vocab.base.objects.actors import as_Actor dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) coordinator = as_Actor(id="https://example.org/users/coordinator") case = VulnerabilityCase( id="https://example.org/cases/case_sa2", name="SA Case 2", ) - activity = RecommendActor( + activity = RecommendActorActivity( actor="https://example.org/users/finder", object=coordinator, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.SUGGEST_ACTOR_TO_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.SUGGEST_ACTOR_TO_CASE ) - mock_dispatchable.payload = activity - handlers.suggest_actor_to_case(mock_dispatchable) - handlers.suggest_actor_to_case(mock_dispatchable) + handlers.suggest_actor_to_case(dispatchable, dl) + handlers.suggest_actor_to_case(dispatchable, dl) # Second call should be a no-op; record is still present (not duplicated) stored = dl.get(activity.as_type.value, activity.as_id) @@ -1214,41 +1535,35 @@ def test_accept_suggest_actor_to_case_persists_acceptance( self, monkeypatch ): """accept_suggest_actor_to_case persists the AcceptActorRecommendation.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.actor import ( - AcceptActorRecommendation, - RecommendActor, + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.actor import ( + AcceptActorRecommendationActivity, + RecommendActorActivity, ) - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.base.objects.actors import as_Actor dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) coordinator = as_Actor(id="https://example.org/users/coordinator") case = VulnerabilityCase( id="https://example.org/cases/case_sa3", name="SA Case 3", ) - recommendation = RecommendActor( + recommendation = RecommendActorActivity( actor="https://example.org/users/finder", object=coordinator, target=case, ) - activity = AcceptActorRecommendation( + activity = AcceptActorRecommendationActivity( actor="https://example.org/users/vendor", object=recommendation, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE ) - mock_dispatchable.payload = activity - handlers.accept_suggest_actor_to_case(mock_dispatchable) + handlers.accept_suggest_actor_to_case(dispatchable, dl) stored = dl.get(activity.as_type.value, activity.as_id) assert stored is not None @@ -1259,35 +1574,33 @@ def test_reject_suggest_actor_to_case_logs_rejection( """reject_suggest_actor_to_case logs rejection without state change.""" import logging - from vultron.as_vocab.activities.actor import ( - RecommendActor, - RejectActorRecommendation, + from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, + RejectActorRecommendationActivity, ) - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.base.objects.actors import as_Actor coordinator = as_Actor(id="https://example.org/users/coordinator") case = VulnerabilityCase( id="https://example.org/cases/case_sa4", name="SA Case 4", ) - recommendation = RecommendActor( + recommendation = RecommendActorActivity( actor="https://example.org/users/finder", object=coordinator, target=case, ) - activity = RejectActorRecommendation( + activity = RejectActorRecommendationActivity( actor="https://example.org/users/vendor", object=recommendation, target=case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE + dispatchable = _make_dispatchable( + activity, MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE ) - mock_dispatchable.payload = activity with caplog.at_level(logging.INFO): - handlers.reject_suggest_actor_to_case(mock_dispatchable) + handlers.reject_suggest_actor_to_case(dispatchable, MagicMock()) assert any("rejected" in r.message.lower() for r in caplog.records) @@ -1297,31 +1610,27 @@ class TestOwnershipTransferHandlers: def test_offer_case_ownership_transfer_persists_offer(self, monkeypatch): """offer_case_ownership_transfer persists the offer.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import OfferCaseOwnershipTransfer + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + OfferCaseOwnershipTransferActivity, + ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) case = VulnerabilityCase( id="https://example.org/cases/case_ot1", name="OT Case 1", ) - activity = OfferCaseOwnershipTransfer( + activity = OfferCaseOwnershipTransferActivity( actor="https://example.org/users/vendor", object=case, target="https://example.org/users/coordinator", ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER + dispatchable = _make_dispatchable( + activity, MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER ) - mock_dispatchable.payload = activity - handlers.offer_case_ownership_transfer(mock_dispatchable) + handlers.offer_case_ownership_transfer(dispatchable, dl) stored = dl.get(activity.as_type.value, activity.as_id) assert stored is not None @@ -1330,17 +1639,13 @@ def test_accept_case_ownership_transfer_updates_attributed_to( self, monkeypatch ): """accept_case_ownership_transfer updates case.attributed_to to new owner.""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import ( - AcceptCaseOwnershipTransfer, - OfferCaseOwnershipTransfer, + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import ( + AcceptCaseOwnershipTransferActivity, + OfferCaseOwnershipTransferActivity, ) dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -1353,7 +1658,7 @@ def test_accept_case_ownership_transfer_updates_attributed_to( ) dl.create(case) - offer = OfferCaseOwnershipTransfer( + offer = OfferCaseOwnershipTransferActivity( id="https://example.org/activities/offer_ot2", actor="https://example.org/users/vendor", object=case, @@ -1361,17 +1666,15 @@ def test_accept_case_ownership_transfer_updates_attributed_to( ) dl.create(offer) - activity = AcceptCaseOwnershipTransfer( + activity = AcceptCaseOwnershipTransferActivity( actor="https://example.org/users/coordinator", object=offer, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER + dispatchable = _make_dispatchable( + activity, MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER ) - mock_dispatchable.payload = activity - handlers.accept_case_ownership_transfer(mock_dispatchable) + handlers.accept_case_ownership_transfer(dispatchable, dl) updated_record = dl.get(case.as_type.value, case.as_id) assert updated_record is not None @@ -1387,33 +1690,31 @@ def test_reject_case_ownership_transfer_logs_rejection( """reject_case_ownership_transfer logs rejection; ownership unchanged.""" import logging - from vultron.as_vocab.activities.case import ( - OfferCaseOwnershipTransfer, - RejectCaseOwnershipTransfer, + from vultron.wire.as2.vocab.activities.case import ( + OfferCaseOwnershipTransferActivity, + RejectCaseOwnershipTransferActivity, ) case = VulnerabilityCase( id="https://example.org/cases/case_ot3", name="OT Case 3", ) - offer = OfferCaseOwnershipTransfer( + offer = OfferCaseOwnershipTransferActivity( id="https://example.org/activities/offer_ot3", actor="https://example.org/users/vendor", object=case, target="https://example.org/users/coordinator", ) - activity = RejectCaseOwnershipTransfer( + activity = RejectCaseOwnershipTransferActivity( actor="https://example.org/users/coordinator", object=offer, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = ( - MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER + dispatchable = _make_dispatchable( + activity, MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER ) - mock_dispatchable.payload = activity with caplog.at_level(logging.INFO): - handlers.reject_case_ownership_transfer(mock_dispatchable) + handlers.reject_case_ownership_transfer(dispatchable, MagicMock()) assert any("rejected" in r.message.lower() for r in caplog.records) @@ -1425,14 +1726,10 @@ def test_update_case_applies_scalar_updates(self, monkeypatch, caplog): """update_case applies name/summary/content updates from a full object.""" import logging - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import UpdateCase + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import UpdateCaseActivity dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -1452,16 +1749,28 @@ def test_update_case_applies_scalar_updates(self, monkeypatch, caplog): content="New content", attributed_to=owner_id, ) - activity = UpdateCase( + activity = UpdateCaseActivity( actor=owner_id, object=updated_case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.UPDATE_CASE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.UPDATE_CASE + ) + + from vultron.api.v2.data.rehydration import rehydrate as real_rehydrate + + def _mock_rehydrate(obj, **kwargs): + if obj == case.as_id: + return updated_case + return real_rehydrate(obj, **kwargs) + + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.rehydrate", + _mock_rehydrate, + ) with caplog.at_level(logging.INFO): - handlers.update_case(mock_dispatchable) + handlers.update_case(dispatchable, dl) stored = dl.read(case.as_id) assert stored is not None @@ -1472,14 +1781,10 @@ def test_update_case_rejects_non_owner(self, monkeypatch, caplog): """update_case logs a warning and skips if actor is not the case owner.""" import logging - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import UpdateCase + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import UpdateCaseActivity dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -1499,16 +1804,16 @@ def test_update_case_rejects_non_owner(self, monkeypatch, caplog): name="Hijacked Name", attributed_to=owner_id, ) - activity = UpdateCase( + activity = UpdateCaseActivity( actor=non_owner_id, object=updated_case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.UPDATE_CASE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.UPDATE_CASE + ) with caplog.at_level(logging.WARNING): - handlers.update_case(mock_dispatchable) + handlers.update_case(dispatchable, dl) stored = dl.read(case.as_id) assert stored is not None @@ -1517,14 +1822,10 @@ def test_update_case_rejects_non_owner(self, monkeypatch, caplog): def test_update_case_idempotent(self, monkeypatch): """update_case with same data produces the same result (last-write-wins).""" - from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer - from vultron.as_vocab.activities.case import UpdateCase + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import UpdateCaseActivity dl = TinyDbDataLayer(db_path=None) - monkeypatch.setattr( - "vultron.api.v2.datalayer.tinydb_backend.get_datalayer", - lambda **_: dl, - ) monkeypatch.setattr( "vultron.api.v2.data.rehydration.get_datalayer", lambda **_: dl, @@ -1543,17 +1844,197 @@ def test_update_case_idempotent(self, monkeypatch): name="Updated", attributed_to=owner_id, ) - activity = UpdateCase( + activity = UpdateCaseActivity( actor=owner_id, object=updated_case, ) - mock_dispatchable = MagicMock(spec=DispatchActivity) - mock_dispatchable.semantic_type = MessageSemantics.UPDATE_CASE - mock_dispatchable.payload = activity + dispatchable = _make_dispatchable( + activity, MessageSemantics.UPDATE_CASE + ) + + from vultron.api.v2.data.rehydration import rehydrate as real_rehydrate - handlers.update_case(mock_dispatchable) - handlers.update_case(mock_dispatchable) + def _mock_rehydrate(obj, **kwargs): + if obj == case.as_id: + return updated_case + return real_rehydrate(obj, **kwargs) + + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.rehydrate", + _mock_rehydrate, + ) + + handlers.update_case(dispatchable, dl) + handlers.update_case(dispatchable, dl) stored = dl.read(case.as_id) assert stored is not None assert stored.name == "Updated" + + def test_update_case_warns_when_participant_has_not_accepted_embargo( + self, monkeypatch, caplog + ): + """update_case logs WARNING per CM-10-004 when a participant has not accepted the active embargo.""" + import logging + + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import UpdateCaseActivity + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + owner_id = "https://example.org/users/owner" + actor_id = "https://example.org/users/alice" + embargo = EmbargoEvent(id="https://example.org/embargoes/em1") + dl.create(embargo) + + participant = CaseParticipant( + id="https://example.org/participants/p1", + attributed_to=actor_id, + context="https://example.org/cases/uc4", + accepted_embargo_ids=[], + ) + dl.create(participant) + + case = VulnerabilityCase( + id="https://example.org/cases/uc4", + name="Original", + attributed_to=owner_id, + active_embargo=embargo.as_id, + ) + case.actor_participant_index[actor_id] = participant.as_id + dl.create(case) + + updated_case = VulnerabilityCase( + id=case.as_id, + name="Updated", + attributed_to=owner_id, + ) + activity = UpdateCaseActivity(actor=owner_id, object=updated_case) + dispatchable = _make_dispatchable( + activity, MessageSemantics.UPDATE_CASE + ) + + with caplog.at_level(logging.WARNING): + handlers.update_case(dispatchable, dl) + + assert any( + "has not accepted" in r.message and "CM-10-004" in r.message + for r in caplog.records + ) + + def test_update_case_no_warning_when_all_participants_accepted_embargo( + self, monkeypatch, caplog + ): + """update_case does NOT warn when all participants have accepted the active embargo (CM-10-004).""" + import logging + + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import UpdateCaseActivity + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + owner_id = "https://example.org/users/owner" + actor_id = "https://example.org/users/bob" + embargo = EmbargoEvent(id="https://example.org/embargoes/em2") + dl.create(embargo) + + participant = CaseParticipant( + id="https://example.org/participants/p2", + attributed_to=actor_id, + context="https://example.org/cases/uc5", + accepted_embargo_ids=[embargo.as_id], + ) + dl.create(participant) + + case = VulnerabilityCase( + id="https://example.org/cases/uc5", + name="Original", + attributed_to=owner_id, + active_embargo=embargo.as_id, + ) + case.actor_participant_index[actor_id] = participant.as_id + dl.create(case) + + updated_case = VulnerabilityCase( + id=case.as_id, + name="Updated", + attributed_to=owner_id, + ) + activity = UpdateCaseActivity(actor=owner_id, object=updated_case) + dispatchable = _make_dispatchable( + activity, MessageSemantics.UPDATE_CASE + ) + + with caplog.at_level(logging.WARNING): + handlers.update_case(dispatchable, dl) + + assert not any("has not accepted" in r.message for r in caplog.records) + + def test_update_case_no_warning_when_no_active_embargo( + self, monkeypatch, caplog + ): + """update_case does NOT warn when there is no active embargo (CM-10-004).""" + import logging + + from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer + from vultron.wire.as2.vocab.activities.case import UpdateCaseActivity + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + + dl = TinyDbDataLayer(db_path=None) + monkeypatch.setattr( + "vultron.api.v2.data.rehydration.get_datalayer", + lambda **_: dl, + ) + + owner_id = "https://example.org/users/owner" + actor_id = "https://example.org/users/carol" + + participant = CaseParticipant( + id="https://example.org/participants/p3", + attributed_to=actor_id, + context="https://example.org/cases/uc6", + accepted_embargo_ids=[], + ) + dl.create(participant) + + case = VulnerabilityCase( + id="https://example.org/cases/uc6", + name="Original", + attributed_to=owner_id, + active_embargo=None, + ) + case.actor_participant_index[actor_id] = participant.as_id + dl.create(case) + + updated_case = VulnerabilityCase( + id=case.as_id, + name="Updated", + attributed_to=owner_id, + ) + activity = UpdateCaseActivity(actor=owner_id, object=updated_case) + dispatchable = _make_dispatchable( + activity, MessageSemantics.UPDATE_CASE + ) + + with caplog.at_level(logging.WARNING): + handlers.update_case(dispatchable, dl) + + assert not any("has not accepted" in r.message for r in caplog.records) diff --git a/test/api/v2/backend/test_inbox_handler.py b/test/api/v2/backend/test_inbox_handler.py index 62d8c3fb..0d006749 100644 --- a/test/api/v2/backend/test_inbox_handler.py +++ b/test/api/v2/backend/test_inbox_handler.py @@ -1,41 +1,41 @@ import asyncio from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import Mock, MagicMock import pytest from vultron.api.v2.backend import inbox_handler as ih -from vultron.api.v2.errors import VultronApiValidationError +from vultron.core.models.events import InboundPayload, MessageSemantics -def test_raise_if_not_valid_activity_raises(monkeypatch): - # Arrange: ensure VOCABULARY.activities does not contain the test type - monkeypatch.setattr( - ih, - "VOCABULARY", - SimpleNamespace(activities={"SomeOtherActivity"}), - raising=False, +def test_prepare_for_dispatch_parses_activity_and_constructs_dispatchactivity( + monkeypatch, +): + """prepare_for_dispatch should parse the passed activity and let pydantic construct the payload model.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, ) - class FakeObj: - as_type = "NotAnActivity" + import vultron.wire.as2.extractor as extractor_mod - obj = FakeObj() + monkeypatch.setattr( + extractor_mod, + "find_matching_semantics", + lambda activity: MessageSemantics.UNKNOWN, + ) - # Act / Assert - with pytest.raises(VultronApiValidationError): - ih.raise_if_not_valid_activity(obj) + mapping_activity = as_Create( + as_id="act-123", actor="actor-1", object="obj-1" + ) + dispatch_msg = ih.prepare_for_dispatch(mapping_activity) + assert dispatch_msg.semantic_type == MessageSemantics.UNKNOWN + assert dispatch_msg.activity_id == "act-123" + assert isinstance(dispatch_msg.payload, InboundPayload) + assert dispatch_msg.payload.activity_id == "act-123" -def test_handle_inbox_item_dispatches(monkeypatch): - # Arrange: make the object look like a valid Activity type - monkeypatch.setattr( - ih, - "VOCABULARY", - SimpleNamespace(activities={"TestActivity"}), - raising=False, - ) +def test_handle_inbox_item_dispatches(monkeypatch): class FakeActivity: as_type = "TestActivity" name = "fake" @@ -51,9 +51,9 @@ def model_dump_json(self, **kwargs): ih, "prepare_for_dispatch", lambda activity: dispatchable ) - # Replace the module DISPATCHER with a Mock dispatcher + # Initialise the module-level dispatcher with a mock mock_dispatcher = Mock() - monkeypatch.setattr(ih, "DISPATCHER", mock_dispatcher, raising=False) + monkeypatch.setattr(ih, "_DISPATCHER", mock_dispatcher) # Act ih.handle_inbox_item(actor_id="actor1", obj=fake_activity) @@ -70,10 +70,8 @@ def test_inbox_handler_retries_and_aborts_after_too_many_errors(monkeypatch): inbox = SimpleNamespace(items=[item]) actor_io = SimpleNamespace(inbox=inbox) - # get_datalayer.read can be a noop (actor not required for this test) - monkeypatch.setattr( - ih, "get_datalayer", lambda: SimpleNamespace(read=lambda aid: None) - ) + mock_dl = MagicMock() + mock_dl.read.return_value = None # get_actor_io should return our actor_io monkeypatch.setattr( @@ -89,9 +87,29 @@ def always_raise(actor_id, obj): monkeypatch.setattr(ih, "handle_inbox_item", always_raise) - # Act: run the async inbox_handler - asyncio.run(ih.inbox_handler("actor-xyz")) + # Act: run the async inbox_handler with the injected dl + asyncio.run(ih.inbox_handler("actor-xyz", mock_dl)) # Assert: after aborting, the item should have been reinserted into the inbox assert len(actor_io.inbox.items) == 1 assert actor_io.inbox.items[0] is item + + +def test_dispatch_raises_if_not_initialised(monkeypatch): + monkeypatch.setattr(ih, "_DISPATCHER", None) + dispatchable = SimpleNamespace(activity_id="x", semantic_type="y") + with pytest.raises(RuntimeError, match="not initialised"): + ih.dispatch(dispatchable) + + +def test_init_dispatcher_sets_dispatcher(monkeypatch): + mock_dl = MagicMock() + mock_dispatcher = Mock() + + monkeypatch.setattr(ih, "_DISPATCHER", None) + monkeypatch.setattr( + ih, "get_dispatcher", lambda handler_map, dl: mock_dispatcher + ) + + ih.init_dispatcher(dl=mock_dl) + assert ih._DISPATCHER is mock_dispatcher diff --git a/test/api/v2/backend/test_trigger_services.py b/test/api/v2/backend/test_trigger_services.py new file mode 100644 index 00000000..a0ea0b6c --- /dev/null +++ b/test/api/v2/backend/test_trigger_services.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Service-layer unit tests for trigger service functions. + +Tests call service functions directly (no HTTP layer) to verify domain +logic independently of the router layer. Each service function accepts +a DataLayer instance as an argument rather than resolving it via +FastAPI's dependency injection. +""" + +import pytest +from fastapi import HTTPException + +from vultron.api.v2.backend.trigger_services.case import ( + svc_defer_case, + svc_engage_case, +) +from vultron.api.v2.backend.trigger_services.embargo import ( + svc_evaluate_embargo, + svc_propose_embargo, + svc_terminate_embargo, +) +from vultron.api.v2.backend.trigger_services.report import ( + svc_close_report, + svc_invalidate_report, + svc_reject_report, + svc_validate_report, +) +from vultron.api.v2.data.actor_io import init_actor_io +from vultron.api.v2.data.status import ReportStatus, set_status +from vultron.adapters.driven.db_record import object_to_record +from vultron.wire.as2.vocab.activities.embargo import EmProposeEmbargoActivity +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Offer +from vultron.wire.as2.vocab.base.objects.actors import as_Service +from vultron.wire.as2.vocab.objects.case_participant import CaseParticipant +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.bt.embargo_management.states import EM +from vultron.bt.report_management.states import RM + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dl(datalayer): + return datalayer + + +@pytest.fixture +def actor(dl): + actor_obj = as_Service(name="Vendor Co") + dl.create(object_to_record(actor_obj)) + init_actor_io(actor_obj.as_id) + return actor_obj + + +@pytest.fixture +def report(dl): + report_obj = VulnerabilityReport( + name="Test Vulnerability", + content="Test content", + ) + dl.create(report_obj) + return report_obj + + +@pytest.fixture +def offer(dl, report, actor): + offer_obj = as_Offer( + actor=actor.as_id, + object=report.as_id, + target=actor.as_id, + ) + dl.create(offer_obj) + return offer_obj + + +@pytest.fixture +def received_report(report, actor): + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.RECEIVED, + ) + ) + return report + + +@pytest.fixture +def accepted_report(report, actor): + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.ACCEPTED, + ) + ) + return report + + +@pytest.fixture +def closed_report(report, actor): + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.CLOSED, + ) + ) + return report + + +@pytest.fixture +def case_with_participant(dl, actor): + case_obj = VulnerabilityCase(name="TEST-CASE-001") + participant = CaseParticipant( + attributed_to=actor.as_id, + context=case_obj.as_id, + ) + case_obj.case_participants.append(participant.as_id) + dl.create(case_obj) + dl.create(participant) + return case_obj + + +@pytest.fixture +def case_no_participant(dl): + case_obj = VulnerabilityCase(name="TEST-CASE-NO-P") + dl.create(case_obj) + return case_obj + + +@pytest.fixture +def case_with_embargo(dl, actor): + case_obj = VulnerabilityCase(name="EMBARGO-CASE-001") + embargo = EmbargoEvent(context=case_obj.as_id) + dl.create(embargo) + case_obj.set_embargo(embargo.as_id) + dl.create(case_obj) + return case_obj, embargo + + +@pytest.fixture +def case_with_proposal(dl, actor): + case_obj = VulnerabilityCase(name="PROPOSAL-CASE-001") + embargo = EmbargoEvent(context=case_obj.as_id) + dl.create(embargo) + proposal = EmProposeEmbargoActivity( + actor=actor.as_id, + object=embargo.as_id, + context=case_obj.as_id, + ) + dl.create(proposal) + case_obj.current_status.em_state = EM.PROPOSED + case_obj.proposed_embargoes.append(embargo.as_id) + dl.create(case_obj) + return case_obj, proposal, embargo + + +# =========================================================================== +# svc_validate_report +# =========================================================================== + + +def test_svc_validate_report_returns_activity_dict( + dl, actor, offer, received_report +): + """svc_validate_report returns dict with 'activity' key.""" + result = svc_validate_report(actor.as_id, offer.as_id, None, dl) + assert isinstance(result, dict) + assert "activity" in result + + +def test_svc_validate_report_unknown_actor_raises_404(dl, offer): + """svc_validate_report raises HTTPException 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_validate_report("urn:uuid:no-such-actor", offer.as_id, None, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_validate_report_unknown_offer_raises_404(dl, actor): + """svc_validate_report raises HTTPException 404 for unknown offer.""" + with pytest.raises(HTTPException) as exc_info: + svc_validate_report(actor.as_id, "urn:uuid:no-such-offer", None, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_validate_report_adds_activity_to_outbox( + dl, actor, offer, received_report +): + """svc_validate_report adds a new activity to the actor's outbox.""" + actor_before = dl.read(actor.as_id) + before = { + item for item in actor_before.outbox.items if isinstance(item, str) + } + + svc_validate_report(actor.as_id, offer.as_id, None, dl) + + actor_after = dl.read(actor.as_id) + after = { + item for item in actor_after.outbox.items if isinstance(item, str) + } + assert len(after - before) >= 1 + + +# =========================================================================== +# svc_invalidate_report +# =========================================================================== + + +def test_svc_invalidate_report_returns_activity_dict( + dl, actor, offer, received_report +): + """svc_invalidate_report returns dict with non-None 'activity'.""" + result = svc_invalidate_report(actor.as_id, offer.as_id, None, dl) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_invalidate_report_unknown_actor_raises_404(dl, offer): + """svc_invalidate_report raises HTTPException 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_invalidate_report("urn:uuid:no-such", offer.as_id, None, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_invalidate_report_unknown_offer_raises_404(dl, actor): + """svc_invalidate_report raises HTTPException 404 for unknown offer.""" + with pytest.raises(HTTPException) as exc_info: + svc_invalidate_report(actor.as_id, "urn:uuid:no-such", None, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_invalidate_report_adds_activity_to_outbox( + dl, actor, offer, received_report +): + """svc_invalidate_report adds a new activity to the actor's outbox.""" + actor_before = dl.read(actor.as_id) + before = { + item for item in actor_before.outbox.items if isinstance(item, str) + } + + svc_invalidate_report(actor.as_id, offer.as_id, None, dl) + + actor_after = dl.read(actor.as_id) + after = { + item for item in actor_after.outbox.items if isinstance(item, str) + } + assert len(after - before) >= 1 + + +# =========================================================================== +# svc_reject_report +# =========================================================================== + + +def test_svc_reject_report_returns_activity_dict( + dl, actor, offer, received_report +): + """svc_reject_report returns dict with non-None 'activity'.""" + result = svc_reject_report(actor.as_id, offer.as_id, "Out of scope.", dl) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_reject_report_unknown_actor_raises_404(dl, offer): + """svc_reject_report raises HTTPException 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_reject_report("urn:uuid:no-such", offer.as_id, "Reason.", dl) + assert exc_info.value.status_code == 404 + + +def test_svc_reject_report_unknown_offer_raises_404(dl, actor): + """svc_reject_report raises HTTPException 404 for unknown offer.""" + with pytest.raises(HTTPException) as exc_info: + svc_reject_report(actor.as_id, "urn:uuid:no-such", "Reason.", dl) + assert exc_info.value.status_code == 404 + + +def test_svc_reject_report_adds_activity_to_outbox( + dl, actor, offer, received_report +): + """svc_reject_report adds a new activity to the actor's outbox.""" + actor_before = dl.read(actor.as_id) + before = { + item for item in actor_before.outbox.items if isinstance(item, str) + } + + svc_reject_report(actor.as_id, offer.as_id, "Reason.", dl) + + actor_after = dl.read(actor.as_id) + after = { + item for item in actor_after.outbox.items if isinstance(item, str) + } + assert len(after - before) >= 1 + + +# =========================================================================== +# svc_close_report +# =========================================================================== + + +def test_svc_close_report_returns_activity_dict( + dl, actor, offer, accepted_report +): + """svc_close_report returns dict with non-None 'activity'.""" + result = svc_close_report(actor.as_id, offer.as_id, None, dl) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_close_report_already_closed_raises_409( + dl, actor, offer, closed_report +): + """svc_close_report raises HTTPException 409 when report is CLOSED.""" + with pytest.raises(HTTPException) as exc_info: + svc_close_report(actor.as_id, offer.as_id, None, dl) + assert exc_info.value.status_code == 409 + + +def test_svc_close_report_unknown_actor_raises_404(dl, offer): + """svc_close_report raises HTTPException 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_close_report("urn:uuid:no-such", offer.as_id, None, dl) + assert exc_info.value.status_code == 404 + + +# =========================================================================== +# svc_engage_case +# =========================================================================== + + +def test_svc_engage_case_returns_activity_dict( + dl, actor, case_with_participant +): + """svc_engage_case returns dict with non-None 'activity'.""" + result = svc_engage_case(actor.as_id, case_with_participant.as_id, dl) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_engage_case_unknown_actor_raises_404(dl, case_with_participant): + """svc_engage_case raises HTTPException 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_engage_case("urn:uuid:no-such", case_with_participant.as_id, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_engage_case_unknown_case_raises_404(dl, actor): + """svc_engage_case raises HTTPException 404 for unknown case.""" + with pytest.raises(HTTPException) as exc_info: + svc_engage_case(actor.as_id, "urn:uuid:no-such-case", dl) + assert exc_info.value.status_code == 404 + + +def test_svc_engage_case_updates_participant_rm_state( + dl, actor, case_with_participant +): + """svc_engage_case transitions actor's CaseParticipant RM state to ACCEPTED.""" + svc_engage_case(actor.as_id, case_with_participant.as_id, dl) + + updated_case = dl.read(case_with_participant.as_id) + for p_ref in updated_case.case_participants: + p_id = p_ref if isinstance(p_ref, str) else p_ref.as_id + p_obj = dl.read(p_id) + if p_obj is None: + continue + actor_ref = p_obj.attributed_to + p_actor_id = ( + actor_ref + if isinstance(actor_ref, str) + else getattr(actor_ref, "as_id", str(actor_ref)) + ) + if p_actor_id == actor.as_id and p_obj.participant_statuses: + assert p_obj.participant_statuses[-1].rm_state == RM.ACCEPTED + return + pytest.fail("Participant RM state was not updated to ACCEPTED") + + +def test_svc_engage_case_adds_activity_to_outbox( + dl, actor, case_with_participant +): + """svc_engage_case adds a new activity to the actor's outbox.""" + actor_before = dl.read(actor.as_id) + before = { + item for item in actor_before.outbox.items if isinstance(item, str) + } + + svc_engage_case(actor.as_id, case_with_participant.as_id, dl) + + actor_after = dl.read(actor.as_id) + after = { + item for item in actor_after.outbox.items if isinstance(item, str) + } + assert len(after - before) >= 1 + + +# =========================================================================== +# svc_defer_case +# =========================================================================== + + +def test_svc_defer_case_returns_activity_dict( + dl, actor, case_with_participant +): + """svc_defer_case returns dict with non-None 'activity'.""" + result = svc_defer_case(actor.as_id, case_with_participant.as_id, dl) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_defer_case_unknown_actor_raises_404(dl, case_with_participant): + """svc_defer_case raises HTTPException 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_defer_case("urn:uuid:no-such", case_with_participant.as_id, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_defer_case_updates_participant_rm_state( + dl, actor, case_with_participant +): + """svc_defer_case transitions actor's CaseParticipant RM state to DEFERRED.""" + svc_defer_case(actor.as_id, case_with_participant.as_id, dl) + + updated_case = dl.read(case_with_participant.as_id) + for p_ref in updated_case.case_participants: + p_id = p_ref if isinstance(p_ref, str) else p_ref.as_id + p_obj = dl.read(p_id) + if p_obj is None: + continue + actor_ref = p_obj.attributed_to + p_actor_id = ( + actor_ref + if isinstance(actor_ref, str) + else getattr(actor_ref, "as_id", str(actor_ref)) + ) + if p_actor_id == actor.as_id and p_obj.participant_statuses: + assert p_obj.participant_statuses[-1].rm_state == RM.DEFERRED + return + pytest.fail("Participant RM state was not updated to DEFERRED") + + +# =========================================================================== +# svc_propose_embargo +# =========================================================================== + + +def test_svc_propose_embargo_returns_activity_dict( + dl, actor, case_no_participant +): + """svc_propose_embargo returns dict with non-None 'activity'.""" + result = svc_propose_embargo( + actor.as_id, case_no_participant.as_id, None, None, dl + ) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_propose_embargo_transitions_em_state_to_proposed( + dl, actor, case_no_participant +): + """svc_propose_embargo transitions case EM state from N to P.""" + svc_propose_embargo(actor.as_id, case_no_participant.as_id, None, None, dl) + updated = dl.read(case_no_participant.as_id) + assert updated.current_status.em_state == EM.PROPOSED + + +def test_svc_propose_embargo_exited_raises_409(dl, actor, case_no_participant): + """svc_propose_embargo raises 409 when EM state is EXITED.""" + case_obj = dl.read(case_no_participant.as_id) + case_obj.current_status.em_state = EM.EXITED + dl.update(case_obj.as_id, object_to_record(case_obj)) + + with pytest.raises(HTTPException) as exc_info: + svc_propose_embargo( + actor.as_id, case_no_participant.as_id, None, None, dl + ) + assert exc_info.value.status_code == 409 + + +def test_svc_propose_embargo_unknown_actor_raises_404(dl, case_no_participant): + """svc_propose_embargo raises 404 for unknown actor.""" + with pytest.raises(HTTPException) as exc_info: + svc_propose_embargo( + "urn:uuid:no-such", case_no_participant.as_id, None, None, dl + ) + assert exc_info.value.status_code == 404 + + +# =========================================================================== +# svc_evaluate_embargo +# =========================================================================== + + +def test_svc_evaluate_embargo_returns_activity_dict( + dl, actor, case_with_proposal +): + """svc_evaluate_embargo returns dict with non-None 'activity'.""" + case_obj, proposal, _ = case_with_proposal + result = svc_evaluate_embargo( + actor.as_id, case_obj.as_id, proposal.as_id, dl + ) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_evaluate_embargo_activates_embargo(dl, actor, case_with_proposal): + """svc_evaluate_embargo sets EM state to ACTIVE.""" + case_obj, proposal, _ = case_with_proposal + svc_evaluate_embargo(actor.as_id, case_obj.as_id, proposal.as_id, dl) + updated = dl.read(case_obj.as_id) + assert updated.current_status.em_state == EM.ACTIVE + assert updated.active_embargo is not None + + +def test_svc_evaluate_embargo_without_proposal_id_finds_first( + dl, actor, case_with_proposal +): + """svc_evaluate_embargo finds the first proposal when proposal_id is None.""" + case_obj, _, _ = case_with_proposal + result = svc_evaluate_embargo(actor.as_id, case_obj.as_id, None, dl) + assert isinstance(result, dict) + updated = dl.read(case_obj.as_id) + assert updated.current_status.em_state == EM.ACTIVE + + +def test_svc_evaluate_embargo_no_proposal_raises_404( + dl, actor, case_no_participant +): + """svc_evaluate_embargo raises 404 when no proposal is found.""" + with pytest.raises(HTTPException) as exc_info: + svc_evaluate_embargo(actor.as_id, case_no_participant.as_id, None, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_evaluate_embargo_unknown_proposal_raises_404( + dl, actor, case_no_participant +): + """svc_evaluate_embargo raises 404 when explicit proposal_id is not found.""" + with pytest.raises(HTTPException) as exc_info: + svc_evaluate_embargo( + actor.as_id, + case_no_participant.as_id, + "urn:uuid:no-such-proposal", + dl, + ) + assert exc_info.value.status_code == 404 + + +# =========================================================================== +# svc_terminate_embargo +# =========================================================================== + + +def test_svc_terminate_embargo_returns_activity_dict( + dl, actor, case_with_embargo +): + """svc_terminate_embargo returns dict with non-None 'activity'.""" + case_obj, _ = case_with_embargo + result = svc_terminate_embargo(actor.as_id, case_obj.as_id, dl) + assert isinstance(result, dict) + assert result["activity"] is not None + + +def test_svc_terminate_embargo_sets_em_state_to_exited( + dl, actor, case_with_embargo +): + """svc_terminate_embargo transitions case EM state to EXITED.""" + case_obj, _ = case_with_embargo + svc_terminate_embargo(actor.as_id, case_obj.as_id, dl) + updated = dl.read(case_obj.as_id) + assert updated.current_status.em_state == EM.EXITED + + +def test_svc_terminate_embargo_clears_active_embargo( + dl, actor, case_with_embargo +): + """svc_terminate_embargo clears active_embargo on the case.""" + case_obj, _ = case_with_embargo + svc_terminate_embargo(actor.as_id, case_obj.as_id, dl) + updated = dl.read(case_obj.as_id) + assert updated.active_embargo is None + + +def test_svc_terminate_embargo_no_active_embargo_raises_409( + dl, actor, case_no_participant +): + """svc_terminate_embargo raises 409 when no active embargo.""" + with pytest.raises(HTTPException) as exc_info: + svc_terminate_embargo(actor.as_id, case_no_participant.as_id, dl) + assert exc_info.value.status_code == 409 + + +def test_svc_terminate_embargo_unknown_actor_raises_404(dl, case_with_embargo): + """svc_terminate_embargo raises 404 for unknown actor.""" + case_obj, _ = case_with_embargo + with pytest.raises(HTTPException) as exc_info: + svc_terminate_embargo("urn:uuid:no-such", case_obj.as_id, dl) + assert exc_info.value.status_code == 404 + + +def test_svc_terminate_embargo_adds_activity_to_outbox( + dl, actor, case_with_embargo +): + """svc_terminate_embargo adds a new activity to the actor's outbox.""" + case_obj, _ = case_with_embargo + actor_before = dl.read(actor.as_id) + before = { + item for item in actor_before.outbox.items if isinstance(item, str) + } + + svc_terminate_embargo(actor.as_id, case_obj.as_id, dl) + + actor_after = dl.read(actor.as_id) + after = { + item for item in actor_after.outbox.items if isinstance(item, str) + } + assert len(after - before) >= 1 diff --git a/test/api/v2/conftest.py b/test/api/v2/conftest.py index d7c76771..4a8783c6 100644 --- a/test/api/v2/conftest.py +++ b/test/api/v2/conftest.py @@ -23,7 +23,7 @@ @pytest.fixture def client(datalayer): - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer + from vultron.adapters.driven.datalayer_tinydb import get_datalayer app.dependency_overrides = {} app.dependency_overrides[get_datalayer] = lambda: datalayer @@ -34,7 +34,7 @@ def client(datalayer): @pytest.fixture def datalayer(): - from vultron.api.v2.datalayer.tinydb_backend import ( + from vultron.adapters.driven.datalayer_tinydb import ( get_datalayer, reset_datalayer, ) diff --git a/test/api/v2/data/test_utils.py b/test/api/v2/data/test_utils.py index 5f8d0ef3..37832765 100644 --- a/test/api/v2/data/test_utils.py +++ b/test/api/v2/data/test_utils.py @@ -46,6 +46,21 @@ def test_parse_id_extracts_components_correctly(test_base_url): assert _UUID_PATTERN.fullmatch(parsed["object_id"]) is not None +def test_base_url_reads_from_vultron_base_url_env_var(monkeypatch): + custom_url = "https://custom.vultron.example/" + monkeypatch.setenv("VULTRON_BASE_URL", custom_url) + import importlib + + importlib.reload(utils) + assert utils.BASE_URL == custom_url + importlib.reload(utils) # restore defaults for other tests + + +def test_make_id_produces_uri_form_id(): + object_id = utils.make_id("Actor") + assert "://" in object_id, "make_id() must return a URI-form ID" + + def test_id_prefix_handles_base_url_without_trailing_slash( monkeypatch, test_base_url ): diff --git a/test/api/v2/datalayer/conftest.py b/test/api/v2/datalayer/conftest.py index 52f45a3e..195689e5 100644 --- a/test/api/v2/datalayer/conftest.py +++ b/test/api/v2/datalayer/conftest.py @@ -13,8 +13,8 @@ import pytest -from vultron.api.v2.datalayer.db_record import Record -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer +from vultron.adapters.driven.db_record import Record +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer @pytest.fixture @@ -62,13 +62,13 @@ def sample_record(): @pytest.fixture def base_object(): - from vultron.as_vocab.base.base import as_Base + from vultron.wire.as2.vocab.base.base import as_Base return as_Base(as_id="test-id", as_type="BaseObject", name="Test Object") @pytest.fixture def note_object(): - from vultron.as_vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.base.objects.object_types import as_Note return as_Note(content="Test Content") diff --git a/test/api/v2/datalayer/test_db_record.py b/test/api/v2/datalayer/test_db_record.py index 5505164b..edf76b35 100644 --- a/test/api/v2/datalayer/test_db_record.py +++ b/test/api/v2/datalayer/test_db_record.py @@ -13,7 +13,7 @@ import pytest -from vultron.api.v2.datalayer.db_record import ( +from vultron.adapters.driven.db_record import ( Record, object_to_record, record_to_object, @@ -28,14 +28,14 @@ def sample_record(): @pytest.fixture def base_object(): - from vultron.as_vocab.base.base import as_Base + from vultron.wire.as2.vocab.base.base import as_Base return as_Base(as_id="test-id", as_type="BaseObject", name="Test Object") @pytest.fixture def note_object(): - from vultron.as_vocab.base.objects.object_types import as_Note + from vultron.wire.as2.vocab.base.objects.object_types import as_Note return as_Note(content="Test Content") diff --git a/test/api/v2/datalayer/test_tinydb_backend.py b/test/api/v2/datalayer/test_tinydb_backend.py index 31be0010..7fb5445f 100644 --- a/test/api/v2/datalayer/test_tinydb_backend.py +++ b/test/api/v2/datalayer/test_tinydb_backend.py @@ -17,8 +17,8 @@ from tinydb.queries import QueryInstance from tinydb.table import Table -from vultron.api.v2.datalayer.db_record import Record -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer +from vultron.adapters.driven.db_record import Record +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer # Fixtures diff --git a/test/api/v2/routers/conftest.py b/test/api/v2/routers/conftest.py index 3094a6df..988baa12 100644 --- a/test/api/v2/routers/conftest.py +++ b/test/api/v2/routers/conftest.py @@ -16,11 +16,11 @@ from fastapi.testclient import TestClient from vultron.api.v2.data.actor_io import init_actor_io -from vultron.api.v2.datalayer.db_record import object_to_record +from vultron.adapters.driven.db_record import object_to_record from vultron.api.v2.routers import actors as actors_router from vultron.api.v2.routers import datalayer as datalayer_router -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.base.objects.actors import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Offer +from vultron.wire.as2.vocab.base.objects.actors import ( as_Actor, as_Organization, as_Person, @@ -28,7 +28,9 @@ as_Application, as_Group, ) -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) # adapter: reuse top-level datalayer fixture for tests that ask for `dl` @@ -40,7 +42,7 @@ def dl(datalayer): # TestClient for datalayer router @pytest.fixture def client_datalayer(dl): - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer + from vultron.adapters.driven.datalayer_tinydb import get_datalayer app = FastAPI() app.include_router(datalayer_router.router) @@ -54,7 +56,7 @@ def client_datalayer(dl): # TestClient for actors router @pytest.fixture def client_actors(dl): - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer + from vultron.adapters.driven.datalayer_tinydb import get_datalayer app = FastAPI() app.include_router(actors_router.router) diff --git a/test/api/v2/routers/test_actors.py b/test/api/v2/routers/test_actors.py index 20dd2cae..3ce68001 100644 --- a/test/api/v2/routers/test_actors.py +++ b/test/api/v2/routers/test_actors.py @@ -15,8 +15,8 @@ from fastapi import status from fastapi.encoders import jsonable_encoder -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.object_types import as_Note +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.object_types import as_Note def test_created_actors_fixture_has_expected_count(created_actors): diff --git a/test/api/v2/routers/test_datalayer.py b/test/api/v2/routers/test_datalayer.py index 7cb2f059..d189c963 100644 --- a/test/api/v2/routers/test_datalayer.py +++ b/test/api/v2/routers/test_datalayer.py @@ -13,7 +13,7 @@ from fastapi import status -from vultron.api.v2.datalayer.db_record import object_to_record +from vultron.adapters.driven.db_record import object_to_record def test_get_offers_returns_empty_dict_when_no_offers(client_datalayer): diff --git a/test/api/v2/routers/test_datalayer_serialization.py b/test/api/v2/routers/test_datalayer_serialization.py index 6d7a3192..fa24e07e 100644 --- a/test/api/v2/routers/test_datalayer_serialization.py +++ b/test/api/v2/routers/test_datalayer_serialization.py @@ -23,9 +23,11 @@ from fastapi.testclient import TestClient from vultron.api.main import app -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) @pytest.fixture diff --git a/test/api/v2/routers/test_trigger_case.py b/test/api/v2/routers/test_trigger_case.py new file mode 100644 index 00000000..719cf950 --- /dev/null +++ b/test/api/v2/routers/test_trigger_case.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Tests for the case trigger endpoints +(POST /actors/{actor_id}/trigger/{engage,defer}-case). + +Verifies TB-01 through TB-07 requirements from specs/triggerable-behaviors.md. +""" + +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from vultron.api.v2.data.actor_io import init_actor_io +from vultron.adapters.driven.db_record import object_to_record +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.api.v2.routers import trigger_case as trigger_case_router +from vultron.wire.as2.vocab.base.objects.actors import as_Service +from vultron.wire.as2.vocab.objects.case_participant import CaseParticipant +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.bt.report_management.states import RM + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dl(datalayer): + return datalayer + + +@pytest.fixture +def client_triggers(dl): + app = FastAPI() + app.include_router(trigger_case_router.router) + app.dependency_overrides[get_datalayer] = lambda: dl + client = TestClient(app) + yield client + app.dependency_overrides = {} + + +@pytest.fixture +def actor(dl): + actor_obj = as_Service(name="Vendor Co") + dl.create(object_to_record(actor_obj)) + init_actor_io(actor_obj.as_id) + return actor_obj + + +@pytest.fixture +def case_with_participant(dl, actor): + """Create a VulnerabilityCase with the actor as a CaseParticipant.""" + case_obj = VulnerabilityCase(name="TEST-CASE-001") + participant = CaseParticipant( + attributed_to=actor.as_id, + context=case_obj.as_id, + ) + case_obj.case_participants.append(participant.as_id) + dl.create(case_obj) + dl.create(participant) + return case_obj + + +@pytest.fixture +def case_without_participant(dl): + """Create a VulnerabilityCase with no CaseParticipant for the actor.""" + case_obj = VulnerabilityCase(name="TEST-CASE-NO-PARTICIPANT") + dl.create(case_obj) + return case_obj + + +# =========================================================================== +# Tests for trigger/engage-case +# =========================================================================== + + +def test_trigger_engage_case_returns_202( + client_triggers, actor, case_with_participant +): + """TB-01-002: POST /actors/{id}/trigger/engage-case returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_engage_case_response_contains_activity_key( + client_triggers, actor, case_with_participant +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_engage_case_missing_case_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing case_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_engage_case_ignores_unknown_fields( + client_triggers, actor, case_with_participant +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": case_with_participant.as_id, "unknown_xyz": 99}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_engage_case_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent-actor/trigger/engage-case", + json={"case_id": "urn:uuid:any-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + assert data["detail"]["activity_id"] is None + + +def test_trigger_engage_case_unknown_case_returns_404(client_triggers, actor): + """TB-01-003: Unknown case_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": "urn:uuid:nonexistent-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_engage_case_adds_activity_to_outbox( + client_triggers, dl, actor, case_with_participant +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_engage_case_updates_participant_rm_state( + client_triggers, dl, actor, case_with_participant +): + """engage-case transitions actor's CaseParticipant RM state to ACCEPTED.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_with_participant.as_id) + participant_ids = [ + (p if isinstance(p, str) else p.as_id) + for p in updated_case.case_participants + ] + found_accepted = False + for p_id in participant_ids: + p_obj = dl.read(p_id) + if p_obj is None: + continue + actor_ref = p_obj.attributed_to + p_actor_id = ( + actor_ref + if isinstance(actor_ref, str) + else getattr(actor_ref, "as_id", str(actor_ref)) + ) + if p_actor_id == actor.as_id and p_obj.participant_statuses: + latest = p_obj.participant_statuses[-1] + if latest.rm_state == RM.ACCEPTED: + found_accepted = True + break + assert found_accepted, "Participant RM state was not updated to ACCEPTED" + + +def test_trigger_engage_case_no_participant_returns_202_with_warning( + client_triggers, actor, case_without_participant, caplog +): + """engage-case succeeds and warns when actor has no participant record.""" + import logging + + with caplog.at_level(logging.WARNING): + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/engage-case", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + assert any("participant" in r.message.lower() for r in caplog.records) + + +# =========================================================================== +# Tests for trigger/defer-case +# =========================================================================== + + +def test_trigger_defer_case_returns_202( + client_triggers, actor, case_with_participant +): + """TB-01-002: POST /actors/{id}/trigger/defer-case returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_defer_case_response_contains_activity_key( + client_triggers, actor, case_with_participant +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_defer_case_missing_case_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing case_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_defer_case_ignores_unknown_fields( + client_triggers, actor, case_with_participant +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={"case_id": case_with_participant.as_id, "extra": "ignored"}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_defer_case_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent/trigger/defer-case", + json={"case_id": "urn:uuid:any"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_defer_case_unknown_case_returns_404(client_triggers, actor): + """TB-01-003: Unknown case_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={"case_id": "urn:uuid:nonexistent"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_defer_case_adds_activity_to_outbox( + client_triggers, dl, actor, case_with_participant +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_defer_case_updates_participant_rm_state( + client_triggers, dl, actor, case_with_participant +): + """defer-case transitions actor's CaseParticipant RM state to DEFERRED.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/defer-case", + json={"case_id": case_with_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_with_participant.as_id) + participant_ids = [ + (p if isinstance(p, str) else p.as_id) + for p in updated_case.case_participants + ] + found_deferred = False + for p_id in participant_ids: + p_obj = dl.read(p_id) + if p_obj is None: + continue + actor_ref = p_obj.attributed_to + p_actor_id = ( + actor_ref + if isinstance(actor_ref, str) + else getattr(actor_ref, "as_id", str(actor_ref)) + ) + if p_actor_id == actor.as_id and p_obj.participant_statuses: + latest = p_obj.participant_statuses[-1] + if latest.rm_state == RM.DEFERRED: + found_deferred = True + break + assert found_deferred, "Participant RM state was not updated to DEFERRED" diff --git a/test/api/v2/routers/test_trigger_embargo.py b/test/api/v2/routers/test_trigger_embargo.py new file mode 100644 index 00000000..4c543706 --- /dev/null +++ b/test/api/v2/routers/test_trigger_embargo.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Tests for the embargo trigger endpoints +(POST /actors/{actor_id}/trigger/{propose,evaluate,terminate}-embargo). + +Verifies TB-01 through TB-07 requirements from specs/triggerable-behaviors.md. +""" + +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from vultron.api.v2.data.actor_io import init_actor_io +from vultron.adapters.driven.db_record import object_to_record +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.api.v2.routers import trigger_embargo as trigger_embargo_router +from vultron.wire.as2.vocab.activities.embargo import EmProposeEmbargoActivity +from vultron.wire.as2.vocab.base.objects.actors import as_Service +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.bt.embargo_management.states import EM + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dl(datalayer): + return datalayer + + +@pytest.fixture +def client_triggers(dl): + app = FastAPI() + app.include_router(trigger_embargo_router.router) + app.dependency_overrides[get_datalayer] = lambda: dl + client = TestClient(app) + yield client + app.dependency_overrides = {} + + +@pytest.fixture +def actor(dl): + actor_obj = as_Service(name="Vendor Co") + dl.create(object_to_record(actor_obj)) + init_actor_io(actor_obj.as_id) + return actor_obj + + +@pytest.fixture +def case_without_participant(dl): + """Create a VulnerabilityCase with no CaseParticipant for the actor.""" + case_obj = VulnerabilityCase(name="TEST-CASE-NO-PARTICIPANT") + dl.create(case_obj) + return case_obj + + +@pytest.fixture +def case_with_embargo(dl, actor): + """A VulnerabilityCase with an active EmbargoEvent.""" + case_obj = VulnerabilityCase(name="EMBARGO-CASE-001") + embargo = EmbargoEvent(context=case_obj.as_id) + dl.create(embargo) + case_obj.set_embargo(embargo.as_id) + dl.create(case_obj) + return case_obj, embargo + + +@pytest.fixture +def case_with_proposal(dl, actor): + """A VulnerabilityCase with a pending EmProposeEmbargoActivity in EM.PROPOSED state.""" + case_obj = VulnerabilityCase(name="PROPOSAL-CASE-001") + embargo = EmbargoEvent(context=case_obj.as_id) + dl.create(embargo) + proposal = EmProposeEmbargoActivity( + actor=actor.as_id, + object=embargo.as_id, + context=case_obj.as_id, + ) + dl.create(proposal) + case_obj.current_status.em_state = EM.PROPOSED + case_obj.proposed_embargoes.append(embargo.as_id) + dl.create(case_obj) + return case_obj, proposal, embargo + + +# =========================================================================== +# Tests for trigger/propose-embargo +# =========================================================================== + + +def test_trigger_propose_embargo_returns_202( + client_triggers, actor, case_without_participant +): + """TB-01-002: POST /actors/{id}/trigger/propose-embargo returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_propose_embargo_response_contains_activity_key( + client_triggers, actor, case_without_participant +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_propose_embargo_missing_case_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing case_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_propose_embargo_ignores_unknown_fields( + client_triggers, actor, case_without_participant +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_without_participant.as_id, "unknown_xyz": 99}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_propose_embargo_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent-actor/trigger/propose-embargo", + json={"case_id": "urn:uuid:any-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_propose_embargo_unknown_case_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown case_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": "urn:uuid:nonexistent-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_propose_embargo_adds_activity_to_outbox( + client_triggers, dl, actor, case_without_participant +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_propose_embargo_updates_em_state_to_proposed( + client_triggers, dl, actor, case_without_participant +): + """propose-embargo transitions case EM state from N to P.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_without_participant.as_id) + assert updated_case.current_status.em_state == EM.PROPOSED + + +def test_trigger_propose_embargo_from_active_updates_em_state_to_revise( + client_triggers, dl, actor, case_with_embargo +): + """propose-embargo transitions case EM state from A to R when embargo is active.""" + case_obj, _ = case_with_embargo + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_obj.as_id) + assert updated_case.current_status.em_state == EM.REVISE + + +def test_trigger_propose_embargo_exited_returns_409( + client_triggers, dl, actor, case_without_participant +): + """propose-embargo returns HTTP 409 when EM state is EXITED.""" + case_obj = dl.read(case_without_participant.as_id) + case_obj.current_status.em_state = EM.EXITED + dl.update(case_obj.as_id, object_to_record(case_obj)) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/propose-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_409_CONFLICT + data = resp.json() + assert data["detail"]["error"] == "Conflict" + + +# =========================================================================== +# Tests for trigger/evaluate-embargo +# =========================================================================== + + +def test_trigger_evaluate_embargo_returns_202( + client_triggers, actor, case_with_proposal +): + """TB-01-002: POST /actors/{id}/trigger/evaluate-embargo returns HTTP 202.""" + case_obj, proposal, _ = case_with_proposal + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_evaluate_embargo_response_contains_activity_key( + client_triggers, actor, case_with_proposal +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + case_obj, proposal, _ = case_with_proposal + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_evaluate_embargo_missing_case_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing case_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_evaluate_embargo_ignores_unknown_fields( + client_triggers, actor, case_with_proposal +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + case_obj, proposal, _ = case_with_proposal + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={ + "case_id": case_obj.as_id, + "proposal_id": proposal.as_id, + "extra": "ignored", + }, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_evaluate_embargo_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent-actor/trigger/evaluate-embargo", + json={"case_id": "urn:uuid:any-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_evaluate_embargo_unknown_case_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown case_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": "urn:uuid:nonexistent-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_evaluate_embargo_unknown_proposal_returns_404( + client_triggers, actor, case_without_participant +): + """TB-01-003: Unknown proposal_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={ + "case_id": case_without_participant.as_id, + "proposal_id": "urn:uuid:nonexistent-proposal", + }, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_evaluate_embargo_no_proposal_returns_404( + client_triggers, actor, case_without_participant +): + """evaluate-embargo returns HTTP 404 when no proposal is found for the case.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_evaluate_embargo_adds_activity_to_outbox( + client_triggers, dl, actor, case_with_proposal +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + case_obj, proposal, _ = case_with_proposal + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_evaluate_embargo_activates_embargo( + client_triggers, dl, actor, case_with_proposal +): + """evaluate-embargo activates the embargo and sets EM state to ACTIVE.""" + case_obj, proposal, embargo = case_with_proposal + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_obj.as_id) + assert updated_case.current_status.em_state == EM.ACTIVE + assert updated_case.active_embargo is not None + + +def test_trigger_evaluate_embargo_without_proposal_id_uses_first_proposal( + client_triggers, dl, actor, case_with_proposal +): + """evaluate-embargo without proposal_id finds the first pending proposal.""" + case_obj, _, _ = case_with_proposal + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/evaluate-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_obj.as_id) + assert updated_case.current_status.em_state == EM.ACTIVE + + +# =========================================================================== +# Tests for trigger/terminate-embargo +# =========================================================================== + + +def test_trigger_terminate_embargo_returns_202( + client_triggers, actor, case_with_embargo +): + """TB-01-002: POST /actors/{id}/trigger/terminate-embargo returns HTTP 202.""" + case_obj, _ = case_with_embargo + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_terminate_embargo_response_contains_activity_key( + client_triggers, actor, case_with_embargo +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + case_obj, _ = case_with_embargo + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_terminate_embargo_missing_case_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing case_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_terminate_embargo_ignores_unknown_fields( + client_triggers, actor, case_with_embargo +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + case_obj, _ = case_with_embargo + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_obj.as_id, "extra": "ignored"}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_terminate_embargo_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent-actor/trigger/terminate-embargo", + json={"case_id": "urn:uuid:any-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_terminate_embargo_unknown_case_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown case_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": "urn:uuid:nonexistent-case"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_terminate_embargo_no_active_embargo_returns_409( + client_triggers, actor, case_without_participant +): + """terminate-embargo returns HTTP 409 when case has no active embargo.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_without_participant.as_id}, + ) + assert resp.status_code == status.HTTP_409_CONFLICT + data = resp.json() + assert data["detail"]["error"] == "Conflict" + + +def test_trigger_terminate_embargo_adds_activity_to_outbox( + client_triggers, dl, actor, case_with_embargo +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + case_obj, _ = case_with_embargo + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_terminate_embargo_updates_em_state_to_exited( + client_triggers, dl, actor, case_with_embargo +): + """terminate-embargo transitions case EM state to EXITED.""" + case_obj, _ = case_with_embargo + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_obj.as_id) + assert updated_case.current_status.em_state == EM.EXITED + + +def test_trigger_terminate_embargo_clears_active_embargo( + client_triggers, dl, actor, case_with_embargo +): + """terminate-embargo clears the active_embargo field on the case.""" + case_obj, _ = case_with_embargo + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/terminate-embargo", + json={"case_id": case_obj.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + updated_case = dl.read(case_obj.as_id) + assert updated_case.active_embargo is None diff --git a/test/api/v2/routers/test_trigger_report.py b/test/api/v2/routers/test_trigger_report.py new file mode 100644 index 00000000..06929b03 --- /dev/null +++ b/test/api/v2/routers/test_trigger_report.py @@ -0,0 +1,636 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Tests for the report trigger endpoints +(POST /actors/{actor_id}/trigger/{validate,invalidate,reject,close}-report). + +Verifies TB-01 through TB-07 requirements from specs/triggerable-behaviors.md. +""" + +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from vultron.api.v2.data.actor_io import init_actor_io +from vultron.api.v2.data.status import ReportStatus, set_status +from vultron.adapters.driven.db_record import object_to_record +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.api.v2.routers import trigger_report as trigger_report_router +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Offer +from vultron.wire.as2.vocab.base.objects.actors import as_Service +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.bt.report_management.states import RM + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dl(datalayer): + return datalayer + + +@pytest.fixture +def client_triggers(dl): + app = FastAPI() + app.include_router(trigger_report_router.router) + app.dependency_overrides[get_datalayer] = lambda: dl + client = TestClient(app) + yield client + app.dependency_overrides = {} + + +@pytest.fixture +def actor(dl): + actor_obj = as_Service(name="Vendor Co") + dl.create(object_to_record(actor_obj)) + init_actor_io(actor_obj.as_id) + return actor_obj + + +@pytest.fixture +def report(dl, actor): + report_obj = VulnerabilityReport( + name="Test Vulnerability", + content="Test content", + ) + dl.create(report_obj) + return report_obj + + +@pytest.fixture +def offer(dl, report, actor): + offer_obj = as_Offer( + actor=actor.as_id, + object=report.as_id, + target=actor.as_id, + ) + dl.create(offer_obj) + return offer_obj + + +@pytest.fixture +def received_report(report, actor): + """Put the report into RM.RECEIVED state.""" + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.RECEIVED, + ) + ) + return report + + +@pytest.fixture +def invalid_report(report, actor): + """Put the report into RM.RECEIVED state (for invalidate/reject triggers).""" + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.RECEIVED, + ) + ) + return report + + +@pytest.fixture +def accepted_report(report, actor): + """Put the report into RM.ACCEPTED state (valid state for close-report).""" + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.ACCEPTED, + ) + ) + return report + + +@pytest.fixture +def closed_report(report, actor): + """Put the report into RM.CLOSED state (triggers 409 on close-report).""" + set_status( + ReportStatus( + object_type="VulnerabilityReport", + object_id=report.as_id, + actor_id=actor.as_id, + status=RM.CLOSED, + ) + ) + return report + + +# =========================================================================== +# Tests for trigger/validate-report +# =========================================================================== + + +def test_trigger_validate_report_returns_202( + client_triggers, actor, offer, received_report +): + """TB-01-002: POST /actors/{id}/trigger/validate-report returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_validate_report_response_contains_activity_key( + client_triggers, actor, offer, received_report +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + + +def test_trigger_validate_report_missing_offer_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing offer_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_validate_report_ignores_unknown_fields( + client_triggers, actor, offer, received_report +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={ + "offer_id": offer.as_id, + "unknown_field_xyz": "should_be_ignored", + "another_unknown": 42, + }, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_validate_report_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent-actor/trigger/validate-report", + json={"offer_id": "urn:uuid:any-offer"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert "detail" in data + assert data["detail"]["status"] == 404 + assert data["detail"]["error"] == "NotFound" + assert "message" in data["detail"] + assert data["detail"]["activity_id"] is None + + +def test_trigger_validate_report_unknown_offer_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown offer_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={"offer_id": "urn:uuid:nonexistent-offer"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_validate_report_with_note_returns_202( + client_triggers, actor, offer, received_report +): + """TB-03-003: Optional note field is accepted and does not cause errors.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={"offer_id": offer.as_id, "note": "Validated after review."}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_validate_report_uses_injected_datalayer( + datalayer, actor, offer, received_report +): + """TB-06-001, TB-06-002: DataLayer is resolved from Depends(get_datalayer).""" + from vultron.adapters.driven.datalayer_tinydb import get_datalayer as gdl + + app = FastAPI() + app.include_router(trigger_report_router.router) + + call_log = [] + + def tracking_dl(): + call_log.append("called") + return datalayer + + app.dependency_overrides[gdl] = tracking_dl + client = TestClient(app) + client.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={"offer_id": offer.as_id}, + ) + app.dependency_overrides = {} + + assert len(call_log) >= 1, "get_datalayer was not called" + + +def test_trigger_validate_report_adds_activity_to_outbox( + client_triggers, dl, actor, offer, received_report +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/validate-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + new_items = outbox_after - outbox_before + assert len(new_items) >= 1, "No new activity was added to the outbox" + + +# =========================================================================== +# Tests for trigger/invalidate-report +# =========================================================================== + + +def test_trigger_invalidate_report_returns_202( + client_triggers, actor, offer, invalid_report +): + """TB-01-002: POST /actors/{id}/trigger/invalidate-report returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_invalidate_report_response_contains_activity_key( + client_triggers, actor, offer, invalid_report +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_invalidate_report_missing_offer_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing offer_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_invalidate_report_ignores_unknown_fields( + client_triggers, actor, offer, invalid_report +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={"offer_id": offer.as_id, "unknown_xyz": "ignored"}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_invalidate_report_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent/trigger/invalidate-report", + json={"offer_id": "urn:uuid:any"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_invalidate_report_unknown_offer_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown offer_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={"offer_id": "urn:uuid:nonexistent"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_invalidate_report_adds_activity_to_outbox( + client_triggers, dl, actor, offer, invalid_report +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_invalidate_report_with_note_returns_202( + client_triggers, actor, offer, invalid_report +): + """TB-03-003: Optional note field is accepted.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/invalidate-report", + json={ + "offer_id": offer.as_id, + "note": "Soft-closing; needs more info.", + }, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +# =========================================================================== +# Tests for trigger/reject-report +# =========================================================================== + + +def test_trigger_reject_report_returns_202( + client_triggers, actor, offer, invalid_report +): + """TB-01-002: POST /actors/{id}/trigger/reject-report returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": offer.as_id, "note": "Out of scope."}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_reject_report_response_contains_activity_key( + client_triggers, actor, offer, invalid_report +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": offer.as_id, "note": "Out of scope."}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_reject_report_missing_note_returns_422( + client_triggers, actor, offer, invalid_report +): + """TB-03-004: reject-report without note field returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_reject_report_empty_note_emits_warning( + client_triggers, actor, offer, invalid_report, caplog +): + """TB-03-004: reject-report with empty note emits a WARNING.""" + import logging + + with caplog.at_level(logging.WARNING): + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": offer.as_id, "note": ""}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + assert any("empty" in r.message.lower() for r in caplog.records) + + +def test_trigger_reject_report_missing_offer_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing offer_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"note": "Some reason."}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_reject_report_ignores_unknown_fields( + client_triggers, actor, offer, invalid_report +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": offer.as_id, "note": "Reason.", "extra_field": 42}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_reject_report_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent/trigger/reject-report", + json={"offer_id": "urn:uuid:any", "note": "Reason."}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_reject_report_unknown_offer_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown offer_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": "urn:uuid:nonexistent", "note": "Reason."}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_reject_report_adds_activity_to_outbox( + client_triggers, dl, actor, offer, invalid_report +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/reject-report", + json={"offer_id": offer.as_id, "note": "Definitively out of scope."}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +# =========================================================================== +# Tests for trigger/close-report +# =========================================================================== + + +def test_trigger_close_report_returns_202( + client_triggers, actor, offer, accepted_report +): + """TB-01-002: POST /actors/{id}/trigger/close-report returns HTTP 202.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_close_report_response_contains_activity_key( + client_triggers, actor, offer, accepted_report +): + """TB-04-001: Successful trigger response body contains 'activity' key.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + data = resp.json() + assert "activity" in data + assert data["activity"] is not None + + +def test_trigger_close_report_missing_offer_id_returns_422( + client_triggers, actor +): + """TB-03-001: Request missing offer_id returns HTTP 422.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_trigger_close_report_ignores_unknown_fields( + client_triggers, actor, offer, accepted_report +): + """TB-03-002: Unknown fields in request body are silently ignored.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": offer.as_id, "unexpected_field": "ignored"}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_close_report_with_note_returns_202( + client_triggers, actor, offer, accepted_report +): + """TB-03-003: Optional note field is accepted.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": offer.as_id, "note": "Resolved upstream."}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + +def test_trigger_close_report_unknown_actor_returns_404(client_triggers): + """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" + resp = client_triggers.post( + "/actors/nonexistent/trigger/close-report", + json={"offer_id": "urn:uuid:any"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + data = resp.json() + assert data["detail"]["error"] == "NotFound" + + +def test_trigger_close_report_unknown_offer_returns_404( + client_triggers, actor +): + """TB-01-003: Unknown offer_id returns HTTP 404.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": "urn:uuid:nonexistent"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_trigger_close_report_adds_activity_to_outbox( + client_triggers, dl, actor, offer, accepted_report +): + """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" + actor_before = dl.read(actor.as_id) + outbox_before = set( + item for item in actor_before.outbox.items if isinstance(item, str) + ) + + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_202_ACCEPTED + + actor_after = dl.read(actor.as_id) + outbox_after = set( + item for item in actor_after.outbox.items if isinstance(item, str) + ) + assert len(outbox_after - outbox_before) >= 1 + + +def test_trigger_close_report_already_closed_returns_409( + client_triggers, actor, offer, closed_report +): + """close-report returns HTTP 409 when the report is already CLOSED.""" + resp = client_triggers.post( + f"/actors/{actor.as_id}/trigger/close-report", + json={"offer_id": offer.as_id}, + ) + assert resp.status_code == status.HTTP_409_CONFLICT + data = resp.json() + assert data["detail"]["error"] == "Conflict" diff --git a/test/api/v2/routers/test_triggers.py b/test/api/v2/routers/test_triggers.py deleted file mode 100644 index 9db08ffd..00000000 --- a/test/api/v2/routers/test_triggers.py +++ /dev/null @@ -1,1464 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2026 Carnegie Mellon University and Contributors. -# - see Contributors.md for a full list of Contributors -# - see ContributionInstructions.md for information on how you can Contribute to this project -# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is -# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed -# with this Software or contact permission@sei.cmu.edu for full terms. -# Created, in part, with funding and support from the United States Government -# (see Acknowledgments file). This program may include and/or can make use of -# certain third party source code, object code, documentation and other files -# ("Third Party Software"). See LICENSE.md for more details. -# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the -# U.S. Patent and Trademark Office by Carnegie Mellon University - -""" -Tests for the trigger endpoints (POST /actors/{actor_id}/trigger/{behavior}). - -Verifies TB-01 through TB-07 requirements from specs/triggerable-behaviors.md. -""" - -import pytest -from fastapi import FastAPI, status -from fastapi.testclient import TestClient - -from vultron.api.v2.data.actor_io import init_actor_io -from vultron.api.v2.data.status import ReportStatus, set_status -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.api.v2.routers import triggers as triggers_router -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.base.objects.actors import as_Service -from vultron.as_vocab.objects.case_participant import CaseParticipant -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.bt.report_management.states import RM - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def dl(datalayer): - return datalayer - - -@pytest.fixture -def client_triggers(dl): - app = FastAPI() - app.include_router(triggers_router.router) - app.dependency_overrides[get_datalayer] = lambda: dl - client = TestClient(app) - yield client - app.dependency_overrides = {} - - -@pytest.fixture -def actor(dl): - actor_obj = as_Service(name="Vendor Co") - dl.create(object_to_record(actor_obj)) - init_actor_io(actor_obj.as_id) - return actor_obj - - -@pytest.fixture -def report(dl, actor): - report_obj = VulnerabilityReport( - name="Test Vulnerability", - content="Test content", - ) - dl.create(report_obj) - return report_obj - - -@pytest.fixture -def offer(dl, report, actor): - offer_obj = as_Offer( - actor=actor.as_id, - object=report.as_id, - target=actor.as_id, - ) - dl.create(offer_obj) - return offer_obj - - -@pytest.fixture -def received_report(report, actor): - """Put the report into RM.RECEIVED state.""" - set_status( - ReportStatus( - object_type="VulnerabilityReport", - object_id=report.as_id, - actor_id=actor.as_id, - status=RM.RECEIVED, - ) - ) - return report - - -# --------------------------------------------------------------------------- -# TB-01-001 / TB-01-002: Endpoint exists and returns 202 -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_returns_202( - client_triggers, actor, offer, received_report -): - """TB-01-002: POST /actors/{id}/trigger/validate-report returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -# --------------------------------------------------------------------------- -# TB-04-001: Response body contains "activity" key -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_response_contains_activity_key( - client_triggers, actor, offer, received_report -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - - -# --------------------------------------------------------------------------- -# TB-03-001: Missing required offer_id returns 422 -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_missing_offer_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing offer_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -# --------------------------------------------------------------------------- -# TB-03-002: Unknown fields in request body are ignored -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_ignores_unknown_fields( - client_triggers, actor, offer, received_report -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={ - "offer_id": offer.as_id, - "unknown_field_xyz": "should_be_ignored", - "another_unknown": 42, - }, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -# --------------------------------------------------------------------------- -# TB-01-003: Unknown actor returns 404 with structured error -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent-actor/trigger/validate-report", - json={"offer_id": "urn:uuid:any-offer"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert "detail" in data - assert data["detail"]["status"] == 404 - assert data["detail"]["error"] == "NotFound" - assert "message" in data["detail"] - assert data["detail"]["activity_id"] is None - - -# --------------------------------------------------------------------------- -# TB-01-003: Unknown offer_id returns 404 with structured error -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_unknown_offer_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown offer_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={"offer_id": "urn:uuid:nonexistent-offer"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -# --------------------------------------------------------------------------- -# TB-03-003: Optional note field is accepted -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_with_note_returns_202( - client_triggers, actor, offer, received_report -): - """TB-03-003: Optional note field is accepted and does not cause errors.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={"offer_id": offer.as_id, "note": "Validated after review."}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -# --------------------------------------------------------------------------- -# TB-06-001 / TB-06-002: DataLayer injected via dependency -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_uses_injected_datalayer( - datalayer, actor, offer, received_report -): - """TB-06-001, TB-06-002: DataLayer is resolved from Depends(get_datalayer).""" - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer as gdl - - app = FastAPI() - app.include_router(triggers_router.router) - - call_log = [] - - def tracking_dl(): - call_log.append("called") - return datalayer - - app.dependency_overrides[gdl] = tracking_dl - client = TestClient(app) - client.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={"offer_id": offer.as_id}, - ) - app.dependency_overrides = {} - - assert len(call_log) >= 1, "get_datalayer was not called" - - -# --------------------------------------------------------------------------- -# TB-07-001: Resulting activity is added to actor's outbox -# --------------------------------------------------------------------------- - - -def test_trigger_validate_report_adds_activity_to_outbox( - client_triggers, dl, actor, offer, received_report -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/validate-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - new_items = outbox_after - outbox_before - assert len(new_items) >= 1, "No new activity was added to the outbox" - - -# =========================================================================== -# Tests for trigger/invalidate-report (TB-02-001) -# =========================================================================== - - -@pytest.fixture -def invalid_report(report, actor): - """Put the report into RM.INVALID state (for invalidate/reject triggers).""" - set_status( - ReportStatus( - object_type="VulnerabilityReport", - object_id=report.as_id, - actor_id=actor.as_id, - status=RM.RECEIVED, - ) - ) - return report - - -def test_trigger_invalidate_report_returns_202( - client_triggers, actor, offer, invalid_report -): - """TB-01-002: POST /actors/{id}/trigger/invalidate-report returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_invalidate_report_response_contains_activity_key( - client_triggers, actor, offer, invalid_report -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_invalidate_report_missing_offer_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing offer_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_invalidate_report_ignores_unknown_fields( - client_triggers, actor, offer, invalid_report -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={"offer_id": offer.as_id, "unknown_xyz": "ignored"}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_invalidate_report_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent/trigger/invalidate-report", - json={"offer_id": "urn:uuid:any"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_invalidate_report_unknown_offer_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown offer_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={"offer_id": "urn:uuid:nonexistent"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_invalidate_report_adds_activity_to_outbox( - client_triggers, dl, actor, offer, invalid_report -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_invalidate_report_with_note_returns_202( - client_triggers, actor, offer, invalid_report -): - """TB-03-003: Optional note field is accepted.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/invalidate-report", - json={ - "offer_id": offer.as_id, - "note": "Soft-closing; needs more info.", - }, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -# =========================================================================== -# Tests for trigger/reject-report (TB-02-001, TB-03-004) -# =========================================================================== - - -def test_trigger_reject_report_returns_202( - client_triggers, actor, offer, invalid_report -): - """TB-01-002: POST /actors/{id}/trigger/reject-report returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": offer.as_id, "note": "Out of scope."}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_reject_report_response_contains_activity_key( - client_triggers, actor, offer, invalid_report -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": offer.as_id, "note": "Out of scope."}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_reject_report_missing_note_returns_422( - client_triggers, actor, offer, invalid_report -): - """TB-03-004: reject-report without note field returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_reject_report_empty_note_emits_warning( - client_triggers, actor, offer, invalid_report, caplog -): - """TB-03-004: reject-report with empty note emits a WARNING.""" - import logging - - with caplog.at_level(logging.WARNING): - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": offer.as_id, "note": ""}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - assert any("empty" in r.message.lower() for r in caplog.records) - - -def test_trigger_reject_report_missing_offer_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing offer_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"note": "Some reason."}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_reject_report_ignores_unknown_fields( - client_triggers, actor, offer, invalid_report -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": offer.as_id, "note": "Reason.", "extra_field": 42}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_reject_report_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent/trigger/reject-report", - json={"offer_id": "urn:uuid:any", "note": "Reason."}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_reject_report_unknown_offer_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown offer_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": "urn:uuid:nonexistent", "note": "Reason."}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_reject_report_adds_activity_to_outbox( - client_triggers, dl, actor, offer, invalid_report -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/reject-report", - json={"offer_id": offer.as_id, "note": "Definitively out of scope."}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -# =========================================================================== -# Tests for trigger/close-report (TB-02-001, P30-4) -# =========================================================================== - - -@pytest.fixture -def accepted_report(report, actor): - """Put the report into RM.ACCEPTED state (valid state for close-report).""" - set_status( - ReportStatus( - object_type="VulnerabilityReport", - object_id=report.as_id, - actor_id=actor.as_id, - status=RM.ACCEPTED, - ) - ) - return report - - -@pytest.fixture -def closed_report(report, actor): - """Put the report into RM.CLOSED state (triggers 409 on close-report).""" - set_status( - ReportStatus( - object_type="VulnerabilityReport", - object_id=report.as_id, - actor_id=actor.as_id, - status=RM.CLOSED, - ) - ) - return report - - -def test_trigger_close_report_returns_202( - client_triggers, actor, offer, accepted_report -): - """TB-01-002: POST /actors/{id}/trigger/close-report returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_close_report_response_contains_activity_key( - client_triggers, actor, offer, accepted_report -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_close_report_missing_offer_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing offer_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_close_report_ignores_unknown_fields( - client_triggers, actor, offer, accepted_report -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": offer.as_id, "unexpected_field": "ignored"}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_close_report_with_note_returns_202( - client_triggers, actor, offer, accepted_report -): - """TB-03-003: Optional note field is accepted.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": offer.as_id, "note": "Resolved upstream."}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_close_report_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent/trigger/close-report", - json={"offer_id": "urn:uuid:any"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_close_report_unknown_offer_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown offer_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": "urn:uuid:nonexistent"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_close_report_adds_activity_to_outbox( - client_triggers, dl, actor, offer, accepted_report -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_close_report_already_closed_returns_409( - client_triggers, actor, offer, closed_report -): - """close-report returns HTTP 409 when the report is already CLOSED.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/close-report", - json={"offer_id": offer.as_id}, - ) - assert resp.status_code == status.HTTP_409_CONFLICT - data = resp.json() - assert data["detail"]["error"] == "Conflict" - - -# =========================================================================== -# Tests for trigger/engage-case and trigger/defer-case (TB-02-001, P30-3) -# =========================================================================== - - -@pytest.fixture -def case_with_participant(dl, actor): - """Create a VulnerabilityCase with the actor as a CaseParticipant.""" - case_obj = VulnerabilityCase(name="TEST-CASE-001") - participant = CaseParticipant( - attributed_to=actor.as_id, - context=case_obj.as_id, - ) - case_obj.case_participants.append(participant.as_id) - dl.create(case_obj) - dl.create(participant) - return case_obj - - -@pytest.fixture -def case_without_participant(dl): - """Create a VulnerabilityCase with no CaseParticipant for the actor.""" - case_obj = VulnerabilityCase(name="TEST-CASE-NO-PARTICIPANT") - dl.create(case_obj) - return case_obj - - -# --------------------------------------------------------------------------- -# engage-case: basic contract -# --------------------------------------------------------------------------- - - -def test_trigger_engage_case_returns_202( - client_triggers, actor, case_with_participant -): - """TB-01-002: POST /actors/{id}/trigger/engage-case returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_engage_case_response_contains_activity_key( - client_triggers, actor, case_with_participant -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_engage_case_missing_case_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing case_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_engage_case_ignores_unknown_fields( - client_triggers, actor, case_with_participant -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": case_with_participant.as_id, "unknown_xyz": 99}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_engage_case_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent-actor/trigger/engage-case", - json={"case_id": "urn:uuid:any-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - assert data["detail"]["activity_id"] is None - - -def test_trigger_engage_case_unknown_case_returns_404(client_triggers, actor): - """TB-01-003: Unknown case_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": "urn:uuid:nonexistent-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_engage_case_adds_activity_to_outbox( - client_triggers, dl, actor, case_with_participant -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_engage_case_updates_participant_rm_state( - client_triggers, dl, actor, case_with_participant -): - """engage-case transitions actor's CaseParticipant RM state to ACCEPTED.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_with_participant.as_id) - participant_ids = [ - (p if isinstance(p, str) else p.as_id) - for p in updated_case.case_participants - ] - found_accepted = False - for p_id in participant_ids: - p_obj = dl.read(p_id) - if p_obj is None: - continue - actor_ref = p_obj.attributed_to - p_actor_id = ( - actor_ref - if isinstance(actor_ref, str) - else getattr(actor_ref, "as_id", str(actor_ref)) - ) - if p_actor_id == actor.as_id and p_obj.participant_statuses: - latest = p_obj.participant_statuses[-1] - if latest.rm_state == RM.ACCEPTED: - found_accepted = True - break - assert found_accepted, "Participant RM state was not updated to ACCEPTED" - - -def test_trigger_engage_case_no_participant_returns_202_with_warning( - client_triggers, actor, case_without_participant, caplog -): - """engage-case succeeds and warns when actor has no participant record.""" - import logging - - with caplog.at_level(logging.WARNING): - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/engage-case", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - assert any("participant" in r.message.lower() for r in caplog.records) - - -# --------------------------------------------------------------------------- -# defer-case: basic contract -# --------------------------------------------------------------------------- - - -def test_trigger_defer_case_returns_202( - client_triggers, actor, case_with_participant -): - """TB-01-002: POST /actors/{id}/trigger/defer-case returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_defer_case_response_contains_activity_key( - client_triggers, actor, case_with_participant -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_defer_case_missing_case_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing case_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_defer_case_ignores_unknown_fields( - client_triggers, actor, case_with_participant -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={"case_id": case_with_participant.as_id, "extra": "ignored"}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_defer_case_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent/trigger/defer-case", - json={"case_id": "urn:uuid:any"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_defer_case_unknown_case_returns_404(client_triggers, actor): - """TB-01-003: Unknown case_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={"case_id": "urn:uuid:nonexistent"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_defer_case_adds_activity_to_outbox( - client_triggers, dl, actor, case_with_participant -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_defer_case_updates_participant_rm_state( - client_triggers, dl, actor, case_with_participant -): - """defer-case transitions actor's CaseParticipant RM state to DEFERRED.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/defer-case", - json={"case_id": case_with_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_with_participant.as_id) - participant_ids = [ - (p if isinstance(p, str) else p.as_id) - for p in updated_case.case_participants - ] - found_deferred = False - for p_id in participant_ids: - p_obj = dl.read(p_id) - if p_obj is None: - continue - actor_ref = p_obj.attributed_to - p_actor_id = ( - actor_ref - if isinstance(actor_ref, str) - else getattr(actor_ref, "as_id", str(actor_ref)) - ) - if p_actor_id == actor.as_id and p_obj.participant_statuses: - latest = p_obj.participant_statuses[-1] - if latest.rm_state == RM.DEFERRED: - found_deferred = True - break - assert found_deferred, "Participant RM state was not updated to DEFERRED" - - -# =========================================================================== -# Tests for EM trigger endpoints (P30-5) -# =========================================================================== - -from vultron.as_vocab.activities.embargo import EmProposeEmbargo -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.bt.embargo_management.states import EM - - -@pytest.fixture -def case_with_embargo(dl, actor): - """A VulnerabilityCase with an active EmbargoEvent.""" - case_obj = VulnerabilityCase(name="EMBARGO-CASE-001") - embargo = EmbargoEvent(context=case_obj.as_id) - dl.create(embargo) - case_obj.set_embargo(embargo.as_id) - dl.create(case_obj) - return case_obj, embargo - - -@pytest.fixture -def case_with_proposal(dl, actor): - """A VulnerabilityCase with a pending EmProposeEmbargo in EM.PROPOSED state.""" - case_obj = VulnerabilityCase(name="PROPOSAL-CASE-001") - embargo = EmbargoEvent(context=case_obj.as_id) - dl.create(embargo) - proposal = EmProposeEmbargo( - actor=actor.as_id, - object=embargo.as_id, - context=case_obj.as_id, - ) - dl.create(proposal) - case_obj.current_status.em_state = EM.PROPOSED - case_obj.proposed_embargoes.append(embargo.as_id) - dl.create(case_obj) - return case_obj, proposal, embargo - - -# --------------------------------------------------------------------------- -# propose-embargo: basic contract -# --------------------------------------------------------------------------- - - -def test_trigger_propose_embargo_returns_202( - client_triggers, actor, case_without_participant -): - """TB-01-002: POST /actors/{id}/trigger/propose-embargo returns HTTP 202.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_propose_embargo_response_contains_activity_key( - client_triggers, actor, case_without_participant -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_propose_embargo_missing_case_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing case_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_propose_embargo_ignores_unknown_fields( - client_triggers, actor, case_without_participant -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_without_participant.as_id, "unknown_xyz": 99}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_propose_embargo_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent-actor/trigger/propose-embargo", - json={"case_id": "urn:uuid:any-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_propose_embargo_unknown_case_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown case_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": "urn:uuid:nonexistent-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_propose_embargo_adds_activity_to_outbox( - client_triggers, dl, actor, case_without_participant -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_propose_embargo_updates_em_state_to_proposed( - client_triggers, dl, actor, case_without_participant -): - """propose-embargo transitions case EM state from N to P.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_without_participant.as_id) - assert updated_case.current_status.em_state == EM.PROPOSED - - -def test_trigger_propose_embargo_from_active_updates_em_state_to_revise( - client_triggers, dl, actor, case_with_embargo -): - """propose-embargo transitions case EM state from A to R when embargo is active.""" - case_obj, _ = case_with_embargo - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_obj.as_id) - assert updated_case.current_status.em_state == EM.REVISE - - -def test_trigger_propose_embargo_exited_returns_409( - client_triggers, dl, actor, case_without_participant -): - """propose-embargo returns HTTP 409 when EM state is EXITED.""" - case_obj = dl.read(case_without_participant.as_id) - case_obj.current_status.em_state = EM.EXITED - dl.update(case_obj.as_id, object_to_record(case_obj)) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/propose-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_409_CONFLICT - data = resp.json() - assert data["detail"]["error"] == "Conflict" - - -# --------------------------------------------------------------------------- -# evaluate-embargo: basic contract -# --------------------------------------------------------------------------- - - -def test_trigger_evaluate_embargo_returns_202( - client_triggers, actor, case_with_proposal -): - """TB-01-002: POST /actors/{id}/trigger/evaluate-embargo returns HTTP 202.""" - case_obj, proposal, _ = case_with_proposal - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_evaluate_embargo_response_contains_activity_key( - client_triggers, actor, case_with_proposal -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - case_obj, proposal, _ = case_with_proposal - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_evaluate_embargo_missing_case_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing case_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_evaluate_embargo_ignores_unknown_fields( - client_triggers, actor, case_with_proposal -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - case_obj, proposal, _ = case_with_proposal - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={ - "case_id": case_obj.as_id, - "proposal_id": proposal.as_id, - "extra": "ignored", - }, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_evaluate_embargo_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent-actor/trigger/evaluate-embargo", - json={"case_id": "urn:uuid:any-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_evaluate_embargo_unknown_case_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown case_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": "urn:uuid:nonexistent-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_evaluate_embargo_unknown_proposal_returns_404( - client_triggers, actor, case_without_participant -): - """TB-01-003: Unknown proposal_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={ - "case_id": case_without_participant.as_id, - "proposal_id": "urn:uuid:nonexistent-proposal", - }, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_evaluate_embargo_no_proposal_returns_404( - client_triggers, actor, case_without_participant -): - """evaluate-embargo returns HTTP 404 when no proposal is found for the case.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_evaluate_embargo_adds_activity_to_outbox( - client_triggers, dl, actor, case_with_proposal -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - case_obj, proposal, _ = case_with_proposal - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_evaluate_embargo_activates_embargo( - client_triggers, dl, actor, case_with_proposal -): - """evaluate-embargo activates the embargo and sets EM state to ACTIVE.""" - case_obj, proposal, embargo = case_with_proposal - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": case_obj.as_id, "proposal_id": proposal.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_obj.as_id) - assert updated_case.current_status.em_state == EM.ACTIVE - assert updated_case.active_embargo is not None - - -def test_trigger_evaluate_embargo_without_proposal_id_uses_first_proposal( - client_triggers, dl, actor, case_with_proposal -): - """evaluate-embargo without proposal_id finds the first pending proposal.""" - case_obj, _, _ = case_with_proposal - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/evaluate-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_obj.as_id) - assert updated_case.current_status.em_state == EM.ACTIVE - - -# --------------------------------------------------------------------------- -# terminate-embargo: basic contract -# --------------------------------------------------------------------------- - - -def test_trigger_terminate_embargo_returns_202( - client_triggers, actor, case_with_embargo -): - """TB-01-002: POST /actors/{id}/trigger/terminate-embargo returns HTTP 202.""" - case_obj, _ = case_with_embargo - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_terminate_embargo_response_contains_activity_key( - client_triggers, actor, case_with_embargo -): - """TB-04-001: Successful trigger response body contains 'activity' key.""" - case_obj, _ = case_with_embargo - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - data = resp.json() - assert "activity" in data - assert data["activity"] is not None - - -def test_trigger_terminate_embargo_missing_case_id_returns_422( - client_triggers, actor -): - """TB-03-001: Request missing case_id returns HTTP 422.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={}, - ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_trigger_terminate_embargo_ignores_unknown_fields( - client_triggers, actor, case_with_embargo -): - """TB-03-002: Unknown fields in request body are silently ignored.""" - case_obj, _ = case_with_embargo - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_obj.as_id, "extra": "ignored"}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - -def test_trigger_terminate_embargo_unknown_actor_returns_404(client_triggers): - """TB-01-003: Unknown actor_id returns HTTP 404 with structured body.""" - resp = client_triggers.post( - "/actors/nonexistent-actor/trigger/terminate-embargo", - json={"case_id": "urn:uuid:any-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - data = resp.json() - assert data["detail"]["error"] == "NotFound" - - -def test_trigger_terminate_embargo_unknown_case_returns_404( - client_triggers, actor -): - """TB-01-003: Unknown case_id returns HTTP 404.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": "urn:uuid:nonexistent-case"}, - ) - assert resp.status_code == status.HTTP_404_NOT_FOUND - - -def test_trigger_terminate_embargo_no_active_embargo_returns_409( - client_triggers, actor, case_without_participant -): - """terminate-embargo returns HTTP 409 when case has no active embargo.""" - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_without_participant.as_id}, - ) - assert resp.status_code == status.HTTP_409_CONFLICT - data = resp.json() - assert data["detail"]["error"] == "Conflict" - - -def test_trigger_terminate_embargo_adds_activity_to_outbox( - client_triggers, dl, actor, case_with_embargo -): - """TB-07-001: Successful trigger adds a new activity to actor's outbox.""" - case_obj, _ = case_with_embargo - actor_before = dl.read(actor.as_id) - outbox_before = set( - item for item in actor_before.outbox.items if isinstance(item, str) - ) - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - actor_after = dl.read(actor.as_id) - outbox_after = set( - item for item in actor_after.outbox.items if isinstance(item, str) - ) - assert len(outbox_after - outbox_before) >= 1 - - -def test_trigger_terminate_embargo_updates_em_state_to_exited( - client_triggers, dl, actor, case_with_embargo -): - """terminate-embargo transitions case EM state to EXITED.""" - case_obj, _ = case_with_embargo - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_obj.as_id) - assert updated_case.current_status.em_state == EM.EXITED - - -def test_trigger_terminate_embargo_clears_active_embargo( - client_triggers, dl, actor, case_with_embargo -): - """terminate-embargo clears the active_embargo field on the case.""" - case_obj, _ = case_with_embargo - - resp = client_triggers.post( - f"/actors/{actor.as_id}/trigger/terminate-embargo", - json={"case_id": case_obj.as_id}, - ) - assert resp.status_code == status.HTTP_202_ACCEPTED - - updated_case = dl.read(case_obj.as_id) - assert updated_case.active_embargo is None diff --git a/test/api/v2/test_v2_api.py b/test/api/v2/test_v2_api.py index 9a1759fe..eadf5001 100644 --- a/test/api/v2/test_v2_api.py +++ b/test/api/v2/test_v2_api.py @@ -19,8 +19,8 @@ Provides API v2 tests """ -from vultron.as_vocab.base.objects.actors import as_Person -from vultron.api.v2.datalayer.db_record import object_to_record +from vultron.wire.as2.vocab.base.objects.actors import as_Person +from vultron.adapters.driven.db_record import object_to_record def test_version(client): diff --git a/test/as_vocab/test_vulnerability_case.py b/test/as_vocab/test_vulnerability_case.py deleted file mode 100644 index 450903b0..00000000 --- a/test/as_vocab/test_vulnerability_case.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for VulnerabilityCase, including set_embargo() bug fix.""" - -from datetime import datetime, timezone - -import pytest - -from vultron.as_vocab.objects.case_status import CaseStatus -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.bt.embargo_management.states import EM - - -class TestVulnerabilityCase: - def test_init_has_one_case_status(self): - case = VulnerabilityCase() - assert len(case.case_statuses) == 1 - - def test_current_status_returns_most_recent(self): - older = CaseStatus(updated=datetime(2025, 1, 1, tzinfo=timezone.utc)) - newer = CaseStatus(updated=datetime(2025, 6, 1, tzinfo=timezone.utc)) - case = VulnerabilityCase() - case.case_statuses = [older, newer] - assert case.current_status is newer - - def test_current_status_handles_none_updated(self): - cs = CaseStatus() - case = VulnerabilityCase() - case.case_statuses = [cs] - # Should return the only element without error - assert case.current_status is cs - - def test_set_embargo_sets_active_embargo(self): - case = VulnerabilityCase() - embargo = EmbargoEvent() - case.set_embargo(embargo) - assert case.active_embargo is embargo - - def test_set_embargo_updates_em_state_on_current_status(self): - case = VulnerabilityCase() - embargo = EmbargoEvent() - # em_state starts as NO_EMBARGO - assert case.current_status.em_state == EM.NO_EMBARGO - case.set_embargo(embargo) - assert case.current_status.em_state == EM.ACTIVE - - def test_set_embargo_updates_most_recent_case_status(self): - older = CaseStatus(updated=datetime(2025, 1, 1, tzinfo=timezone.utc)) - newer = CaseStatus(updated=datetime(2025, 6, 1, tzinfo=timezone.utc)) - case = VulnerabilityCase() - case.case_statuses = [older, newer] - embargo = EmbargoEvent() - case.set_embargo(embargo) - assert newer.em_state == EM.ACTIVE - assert older.em_state == EM.NO_EMBARGO diff --git a/test/core/__init__.py b/test/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/behaviors/__init__.py b/test/core/behaviors/__init__.py similarity index 100% rename from test/behaviors/__init__.py rename to test/core/behaviors/__init__.py diff --git a/test/behaviors/case/__init__.py b/test/core/behaviors/case/__init__.py similarity index 100% rename from test/behaviors/case/__init__.py rename to test/core/behaviors/case/__init__.py diff --git a/test/behaviors/case/test_create_tree.py b/test/core/behaviors/case/test_create_tree.py similarity index 62% rename from test/behaviors/case/test_create_tree.py rename to test/core/behaviors/case/test_create_tree.py index 634961fc..947113a3 100644 --- a/test/behaviors/case/test_create_tree.py +++ b/test/core/behaviors/case/test_create_tree.py @@ -26,12 +26,14 @@ import pytest from py_trees.common import Status -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.as_vocab.base.objects.actors import as_Service -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.behaviors.bridge import BTBridge -from vultron.behaviors.case.create_tree import create_create_case_tree +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.core.models.vultron_types import ( + VultronCase, + VultronCaseActor, + VultronReport, +) +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.case.create_tree import create_create_case_tree @pytest.fixture @@ -46,14 +48,14 @@ def actor_id(): @pytest.fixture def actor(datalayer, actor_id): - obj = as_Service(as_id=actor_id, name="Vendor Co") + obj = VultronCaseActor(as_id=actor_id, name="Vendor Co") datalayer.create(obj) return obj @pytest.fixture def report(datalayer): - obj = VulnerabilityReport( + obj = VultronReport( as_id="https://example.org/reports/CVE-2024-001", name="Test Vulnerability Report", content="Buffer overflow in component X", @@ -64,10 +66,10 @@ def report(datalayer): @pytest.fixture def case_obj(report): - return VulnerabilityCase( + return VultronCase( as_id="https://example.org/cases/case-001", name="Test Case", - vulnerability_reports=[report], + vulnerability_reports=[report.as_id], ) @@ -91,7 +93,7 @@ def test_create_create_case_tree_returns_selector(case_obj, actor_id): def test_create_case_tree_first_child_is_idempotency_check(case_obj, actor_id): tree = create_create_case_tree(case_obj=case_obj, actor_id=actor_id) - from vultron.behaviors.case.nodes import CheckCaseAlreadyExists + from vultron.core.behaviors.case.nodes import CheckCaseAlreadyExists assert isinstance(tree.children[0], CheckCaseAlreadyExists) @@ -129,8 +131,6 @@ def test_create_case_tree_creates_case_actor( ): tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) bridge.execute_with_setup(tree=tree, actor_id=actor.as_id, activity=None) - from vultron.as_vocab.objects.case_actor import CaseActor - all_objects = datalayer.get_all("Service") case_actors = [ r @@ -211,7 +211,6 @@ def test_create_case_tree_creates_vendor_participant( datalayer, actor, case_obj, bridge ): """A VendorParticipant SHOULD be created and added to case_participants (CM-02-008).""" - from vultron.as_vocab.objects.case_participant import VendorParticipant from vultron.bt.roles.states import CVDRoles as CVDRole tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) @@ -238,3 +237,120 @@ def test_create_case_tree_creates_vendor_participant( if found_vendor: break assert found_vendor, "VendorParticipant was not found in DataLayer" + + +# ============================================================================ +# CM-02-009 event log backfill tests (TECHDEBT-10) +# ============================================================================ + + +def test_create_case_tree_records_case_created_event( + datalayer, actor, case_obj, bridge +): + """A case_created event MUST be recorded in the case event log (CM-02-009).""" + tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) + bridge.execute_with_setup(tree=tree, actor_id=actor.as_id, activity=None) + + stored = datalayer.read(case_obj.as_id) + assert stored is not None + event_types = [e.event_type for e in stored.events] + assert "case_created" in event_types + + +def test_create_case_tree_case_created_event_uses_case_id( + datalayer, actor, case_obj, bridge +): + """The case_created event MUST reference the case ID as object_id (CM-02-009).""" + tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) + bridge.execute_with_setup(tree=tree, actor_id=actor.as_id, activity=None) + + stored = datalayer.read(case_obj.as_id) + assert stored is not None + created_events = [ + e for e in stored.events if e.event_type == "case_created" + ] + assert len(created_events) == 1 + assert created_events[0].object_id == case_obj.as_id + + +def test_create_case_tree_records_offer_received_event_when_present( + datalayer, actor, case_obj, bridge +): + """If the triggering activity has in_reply_to, an offer_received event MUST be recorded (CM-02-009).""" + from vultron.core.models.vultron_types import VultronOffer + + offer_id = "https://example.org/activities/offer-001" + + class FakeActivity: + in_reply_to = VultronOffer(as_id=offer_id) + + tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) + bridge.execute_with_setup( + tree=tree, actor_id=actor.as_id, activity=FakeActivity() + ) + + stored = datalayer.read(case_obj.as_id) + assert stored is not None + event_types = [e.event_type for e in stored.events] + assert "offer_received" in event_types + + offer_events = [ + e for e in stored.events if e.event_type == "offer_received" + ] + assert len(offer_events) == 1 + assert offer_events[0].object_id == offer_id + + +def test_create_case_tree_no_offer_received_event_without_in_reply_to( + datalayer, actor, case_obj, bridge +): + """If the triggering activity has no in_reply_to, no offer_received event is recorded.""" + tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) + bridge.execute_with_setup(tree=tree, actor_id=actor.as_id, activity=None) + + stored = datalayer.read(case_obj.as_id) + assert stored is not None + event_types = [e.event_type for e in stored.events] + assert "offer_received" not in event_types + + +def test_create_case_tree_offer_received_before_case_created( + datalayer, actor, case_obj, bridge +): + """offer_received event MUST appear before case_created in the event log.""" + from vultron.core.models.vultron_types import VultronOffer + + offer_id = "https://example.org/activities/offer-002" + + class FakeActivity: + in_reply_to = VultronOffer(as_id=offer_id) + + tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) + bridge.execute_with_setup( + tree=tree, actor_id=actor.as_id, activity=FakeActivity() + ) + + stored = datalayer.read(case_obj.as_id) + assert stored is not None + event_types = [e.event_type for e in stored.events] + assert event_types.index("offer_received") < event_types.index( + "case_created" + ) + + +def test_create_case_tree_events_have_trusted_timestamps( + datalayer, actor, case_obj, bridge +): + """Case event timestamps MUST be server-generated (CM-02-009).""" + from datetime import timezone + + tree = create_create_case_tree(case_obj=case_obj, actor_id=actor.as_id) + bridge.execute_with_setup(tree=tree, actor_id=actor.as_id, activity=None) + + stored = datalayer.read(case_obj.as_id) + assert stored is not None + assert len(stored.events) >= 1 + for evt in stored.events: + assert evt.received_at is not None + assert evt.received_at.tzinfo is not None + assert evt.received_at.tzinfo == timezone.utc diff --git a/test/behaviors/conftest.py b/test/core/behaviors/conftest.py similarity index 100% rename from test/behaviors/conftest.py rename to test/core/behaviors/conftest.py diff --git a/test/behaviors/report/__init__.py b/test/core/behaviors/report/__init__.py similarity index 100% rename from test/behaviors/report/__init__.py rename to test/core/behaviors/report/__init__.py diff --git a/test/behaviors/report/test_nodes.py b/test/core/behaviors/report/test_nodes.py similarity index 96% rename from test/behaviors/report/test_nodes.py rename to test/core/behaviors/report/test_nodes.py index 84e4fdf8..610b39b3 100644 --- a/test/behaviors/report/test_nodes.py +++ b/test/core/behaviors/report/test_nodes.py @@ -28,11 +28,13 @@ get_status_layer, set_status, ) -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.base.objects.actors import as_Service -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.behaviors.report.nodes import ( +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.core.models.vultron_types import ( + VultronCaseActor, + VultronOffer, + VultronReport, +) +from vultron.core.behaviors.report.nodes import ( CheckRMStateReceivedOrInvalid, CheckRMStateValid, CreateCaseActivity, @@ -44,7 +46,7 @@ UpdateActorOutbox, ) from vultron.bt.report_management.states import RM -from vultron.enums import OfferStatusEnum +from vultron.core.models.status import OfferStatusEnum @pytest.fixture @@ -56,9 +58,8 @@ def datalayer(): @pytest.fixture def actor(datalayer): """Create test actor.""" - actor = as_Service( + actor = VultronCaseActor( name="Test Actor", - url="https://example.org/actor", ) datalayer.create(actor) return actor @@ -67,7 +68,7 @@ def actor(datalayer): @pytest.fixture def report(datalayer): """Create test report.""" - report = VulnerabilityReport( + report = VultronReport( name="TEST-001", content="Test vulnerability report", ) @@ -78,7 +79,7 @@ def report(datalayer): @pytest.fixture def offer(datalayer, report, actor): """Create test offer activity.""" - offer = as_Offer(actor=actor.as_id, object=report) + offer = VultronOffer(actor=actor.as_id, object=report.as_id) datalayer.create(offer) return offer diff --git a/test/behaviors/report/test_policy.py b/test/core/behaviors/report/test_policy.py similarity index 92% rename from test/behaviors/report/test_policy.py rename to test/core/behaviors/report/test_policy.py index 27067085..df1d4e33 100644 --- a/test/behaviors/report/test_policy.py +++ b/test/core/behaviors/report/test_policy.py @@ -25,8 +25,8 @@ import pytest -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.behaviors.report.policy import ( +from vultron.core.models.vultron_types import VultronReport +from vultron.core.behaviors.report.policy import ( AlwaysAcceptPolicy, ValidationPolicy, ) @@ -38,7 +38,7 @@ class TestValidationPolicy: def test_abstract_is_credible_raises(self): """ValidationPolicy.is_credible() raises NotImplementedError.""" policy = ValidationPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -50,7 +50,7 @@ def test_abstract_is_credible_raises(self): def test_abstract_is_valid_raises(self): """ValidationPolicy.is_valid() raises NotImplementedError.""" policy = ValidationPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -72,7 +72,7 @@ def is_valid(self, report): policy = CustomPolicy() # Short name → not credible - report1 = VulnerabilityReport( + report1 = VultronReport( as_id="https://example.org/reports/r1", name="CVE-1", content="Vulnerability found", @@ -81,7 +81,7 @@ def is_valid(self, report): assert policy.is_valid(report1) # Long name → credible - report2 = VulnerabilityReport( + report2 = VultronReport( as_id="https://example.org/reports/r2", name="CVE-2024-12345", content="Vulnerability found", @@ -90,7 +90,7 @@ def is_valid(self, report): assert policy.is_valid(report2) # No keyword → invalid - report3 = VulnerabilityReport( + report3 = VultronReport( as_id="https://example.org/reports/r3", name="CVE-2024-12345", content="Bug found", @@ -105,7 +105,7 @@ class TestAlwaysAcceptPolicy: def test_is_credible_returns_true(self): """AlwaysAcceptPolicy.is_credible() always returns True.""" policy = AlwaysAcceptPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -116,7 +116,7 @@ def test_is_credible_returns_true(self): def test_is_valid_returns_true(self): """AlwaysAcceptPolicy.is_valid() always returns True.""" policy = AlwaysAcceptPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -127,7 +127,7 @@ def test_is_valid_returns_true(self): def test_is_credible_logs_at_info_level(self, caplog): """AlwaysAcceptPolicy.is_credible() logs acceptance at INFO level.""" policy = AlwaysAcceptPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -146,7 +146,7 @@ def test_is_credible_logs_at_info_level(self, caplog): def test_is_valid_logs_at_info_level(self, caplog): """AlwaysAcceptPolicy.is_valid() logs acceptance at INFO level.""" policy = AlwaysAcceptPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -167,17 +167,17 @@ def test_multiple_reports_always_accepted(self): policy = AlwaysAcceptPolicy() reports = [ - VulnerabilityReport( + VultronReport( as_id="https://example.org/reports/r1", name="CVE-2024-001", content="Buffer overflow", ), - VulnerabilityReport( + VultronReport( as_id="https://example.org/reports/r2", name="", # Empty name content="", # Empty content ), - VulnerabilityReport( + VultronReport( as_id="https://example.org/reports/r3", name="X" * 1000, # Very long name content="Y" * 10000, # Very long content @@ -196,12 +196,12 @@ def test_policy_reusable_across_reports(self): """Single AlwaysAcceptPolicy instance can evaluate multiple reports.""" policy = AlwaysAcceptPolicy() - report1 = VulnerabilityReport( + report1 = VultronReport( as_id="https://example.org/reports/r1", name="Report 1", content="Content 1", ) - report2 = VulnerabilityReport( + report2 = VultronReport( as_id="https://example.org/reports/r2", name="Report 2", content="Content 2", @@ -218,7 +218,7 @@ def test_policy_reusable_across_reports(self): def test_policy_does_not_mutate_report(self): """AlwaysAcceptPolicy does not modify report object.""" policy = AlwaysAcceptPolicy() - report = VulnerabilityReport( + report = VultronReport( as_id="https://example.org/reports/test-001", name="TEST-001", content="Test report", @@ -247,7 +247,7 @@ def test_log_messages_include_report_id(self, caplog): """Policy log messages include report ID for traceability.""" policy = AlwaysAcceptPolicy() report_id = "https://example.org/reports/traced-report-123" - report = VulnerabilityReport( + report = VultronReport( as_id=report_id, name="TRACED-123", content="Traceable report", diff --git a/test/behaviors/report/test_prioritize_tree.py b/test/core/behaviors/report/test_prioritize_tree.py similarity index 92% rename from test/behaviors/report/test_prioritize_tree.py rename to test/core/behaviors/report/test_prioritize_tree.py index 738b0528..bbbc6317 100644 --- a/test/behaviors/report/test_prioritize_tree.py +++ b/test/core/behaviors/report/test_prioritize_tree.py @@ -24,13 +24,15 @@ import pytest from py_trees.common import Status -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.as_vocab.base.objects.actors import as_Service -from vultron.as_vocab.objects.case_participant import CaseParticipant -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.behaviors.bridge import BTBridge -from vultron.behaviors.report.prioritize_tree import ( +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.core.models.vultron_types import ( + VultronCase, + VultronCaseActor, + VultronParticipant, + VultronReport, +) +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.report.prioritize_tree import ( create_defer_case_tree, create_engage_case_tree, ) @@ -49,14 +51,14 @@ def actor_id(): @pytest.fixture def actor(datalayer, actor_id): - obj = as_Service(as_id=actor_id, name="Vendor Co") + obj = VultronCaseActor(as_id=actor_id, name="Vendor Co") datalayer.create(obj) return obj @pytest.fixture def report(datalayer): - obj = VulnerabilityReport( + obj = VultronReport( as_id="https://example.org/reports/CVE-2024-001", name="Test Report", content="Buffer overflow", @@ -68,15 +70,15 @@ def report(datalayer): @pytest.fixture def case_with_participant(datalayer, actor_id, actor, report): """Case with the test actor as a CaseParticipant.""" - participant = CaseParticipant( + participant = VultronParticipant( as_id="https://example.org/participants/vendor-cp-001", attributed_to=actor_id, context="https://example.org/cases/case-001", ) - case = VulnerabilityCase( + case = VultronCase( as_id="https://example.org/cases/case-001", name="Test Case", - vulnerability_reports=[report], + vulnerability_reports=[report.as_id], case_participants=[participant], ) datalayer.create(case) @@ -86,10 +88,10 @@ def case_with_participant(datalayer, actor_id, actor, report): @pytest.fixture def case_without_participant(datalayer, report): """Case with no participants.""" - case = VulnerabilityCase( + case = VultronCase( as_id="https://example.org/cases/case-002", name="Test Case No Participants", - vulnerability_reports=[report], + vulnerability_reports=[report.as_id], case_participants=[], ) datalayer.create(case) @@ -244,20 +246,20 @@ def test_engage_only_affects_target_actor(bridge, datalayer, report): actor_a = "https://example.org/actors/vendor-a" actor_b = "https://example.org/actors/vendor-b" - participant_a = CaseParticipant( + participant_a = VultronParticipant( as_id="https://example.org/participants/cp-a", attributed_to=actor_a, context="https://example.org/cases/case-multi", ) - participant_b = CaseParticipant( + participant_b = VultronParticipant( as_id="https://example.org/participants/cp-b", attributed_to=actor_b, context="https://example.org/cases/case-multi", ) - case = VulnerabilityCase( + case = VultronCase( as_id="https://example.org/cases/case-multi", name="Multi-participant case", - vulnerability_reports=[report], + vulnerability_reports=[report.as_id], case_participants=[participant_a, participant_b], ) datalayer.create(case) diff --git a/test/behaviors/report/test_validate_tree.py b/test/core/behaviors/report/test_validate_tree.py similarity index 96% rename from test/behaviors/report/test_validate_tree.py rename to test/core/behaviors/report/test_validate_tree.py index fd370f27..067658cd 100644 --- a/test/behaviors/report/test_validate_tree.py +++ b/test/core/behaviors/report/test_validate_tree.py @@ -30,14 +30,16 @@ get_status_layer, set_status, ) -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.base.objects.actors import as_Service -from vultron.as_vocab.objects.vulnerability_report import ( - VulnerabilityReport, +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.core.models.vultron_types import ( + VultronCaseActor, + VultronOffer, + VultronReport, +) +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.report.validate_tree import ( + create_validate_report_tree, ) -from vultron.behaviors.bridge import BTBridge -from vultron.behaviors.report.validate_tree import create_validate_report_tree from vultron.bt.report_management.states import RM @@ -56,7 +58,7 @@ def actor_id(): @pytest.fixture def report(datalayer, actor_id): """Create test VulnerabilityReport.""" - report_obj = VulnerabilityReport( + report_obj = VultronReport( as_id="https://example.org/reports/CVE-2024-001", name="Test Vulnerability Report", content="Test vulnerability description", @@ -68,7 +70,7 @@ def report(datalayer, actor_id): @pytest.fixture def offer(datalayer, report, actor_id): """Create test Offer activity.""" - offer_obj = as_Offer( + offer_obj = VultronOffer( as_id="https://example.org/activities/offer-123", actor=actor_id, object=report.as_id, @@ -81,7 +83,7 @@ def offer(datalayer, report, actor_id): @pytest.fixture def actor(datalayer, actor_id): """Create test actor.""" - actor_obj = as_Service( + actor_obj = VultronCaseActor( as_id=actor_id, name="Vendor Co", ) @@ -457,7 +459,7 @@ def test_tree_execution_actor_isolation( # Create both actors for aid in [actor_a, actor_b]: - actor_obj = as_Service(as_id=aid, name=f"Actor {aid}") + actor_obj = VultronCaseActor(as_id=aid, name=f"Actor {aid}") datalayer.create(actor_obj) # Set both actors to RECEIVED state diff --git a/test/behaviors/test_bridge.py b/test/core/behaviors/test_bridge.py similarity index 98% rename from test/behaviors/test_bridge.py rename to test/core/behaviors/test_bridge.py index 460d097a..a181b517 100644 --- a/test/behaviors/test_bridge.py +++ b/test/core/behaviors/test_bridge.py @@ -19,8 +19,8 @@ import py_trees from py_trees.common import Status -from vultron.behaviors.bridge import BTBridge, BTExecutionResult -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer +from vultron.core.behaviors.bridge import BTBridge, BTExecutionResult +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer # Test behavior nodes for verifying bridge functionality diff --git a/test/behaviors/test_helpers.py b/test/core/behaviors/test_helpers.py similarity index 98% rename from test/behaviors/test_helpers.py rename to test/core/behaviors/test_helpers.py index cda2ea67..dff67273 100644 --- a/test/behaviors/test_helpers.py +++ b/test/core/behaviors/test_helpers.py @@ -19,16 +19,16 @@ import py_trees from py_trees.common import Status -from vultron.behaviors.helpers import ( +from vultron.core.behaviors.helpers import ( DataLayerCondition, DataLayerAction, ReadObject, UpdateObject, CreateObject, ) -from vultron.behaviors.bridge import BTBridge -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.api.v2.datalayer.db_record import Record +from vultron.core.behaviors.bridge import BTBridge +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.adapters.driven.db_record import Record # Test implementation of abstract base classes diff --git a/test/behaviors/test_performance.py b/test/core/behaviors/test_performance.py similarity index 82% rename from test/behaviors/test_performance.py rename to test/core/behaviors/test_performance.py index 4a613888..a077437c 100644 --- a/test/behaviors/test_performance.py +++ b/test/core/behaviors/test_performance.py @@ -29,12 +29,18 @@ import pytest from py_trees.common import Status -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.as_vocab.base.objects.activities.transitive import as_Accept -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.behaviors.bridge import BTBridge -from vultron.behaviors.report.validate_tree import create_validate_report_tree +from vultron.core.ports.datalayer import DataLayer +from vultron.core.models.vultron_types import ( + VultronAccept, + VultronCase, + VultronCaseActor, + VultronOffer, + VultronReport, +) +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.report.validate_tree import ( + create_validate_report_tree, +) @pytest.fixture @@ -70,31 +76,21 @@ def mock_get(table, id_): # Mock read() for nodes that use TinyDB-specific method def mock_read(id_, raise_on_missing=False): if "report" in id_: - report = VulnerabilityReport( - name="TEST-REPORT", content="Test vulnerability report" + return VultronReport( + as_id=id_, + name="TEST-REPORT", + content="Test vulnerability report", ) - report.as_id = id_ # Override ID - return report elif "offer" in id_: - return as_Offer( + return VultronOffer( + as_id=id_, actor="https://example.org/finder", - as_object="test-report-123", + object="test-report-123", ) elif "case" in id_: - from vultron.as_vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - - case = VulnerabilityCase(name="Test Case") - case.as_id = id_ - return case - elif id_.startswith("https://example.org/"): # Actor IDs - from vultron.as_vocab.base.objects.actors import as_Actor - - actor = as_Actor() - actor.as_id = id_ - actor.name = "Test Actor" - return actor + return VultronCase(as_id=id_, name="Test Case") + elif id_.startswith("https://example.org/"): + return VultronCaseActor(as_id=id_, name="Test Actor") if raise_on_missing: raise ValueError(f"Object not found: {id_}") return None @@ -110,19 +106,19 @@ def mock_read(id_, raise_on_missing=False): @pytest.fixture def sample_activity(): """Sample validation activity for testing.""" - report = VulnerabilityReport( + report = VultronReport( name="TEST-PERF-001", content="Performance test report", ) - offer = as_Offer( + offer = VultronOffer( actor="https://example.org/finder", - as_object=report, + object=report.as_id, ) - return as_Accept( + return VultronAccept( actor="https://example.org/vendor", - as_object=offer, + object=offer.as_id, ) diff --git a/test/demo/conftest.py b/test/demo/conftest.py index 23402842..cd954765 100644 --- a/test/demo/conftest.py +++ b/test/demo/conftest.py @@ -30,5 +30,11 @@ @pytest.fixture(scope="module") def client(): - """Provides a shared TestClient instance for demo tests in this module.""" - return TestClient(api_app) + """Provides a shared TestClient instance for demo tests in this module. + + Uses the context-manager form so the FastAPI lifespan events (startup and + shutdown) are triggered, which initialises the inbox dispatcher via + :func:`vultron.api.v2.backend.inbox_handler.init_dispatcher`. + """ + with TestClient(api_app) as test_client: + yield test_client diff --git a/test/demo/test_acknowledge_demo.py b/test/demo/test_acknowledge_demo.py index bd67c54a..99b1a6c6 100644 --- a/test/demo/test_acknowledge_demo.py +++ b/test/demo/test_acknowledge_demo.py @@ -53,9 +53,9 @@ def test_demo(demo_env, demo_fn, caplog): Tests that each acknowledge demo workflow completes successfully with no errors. Covers: - - demo_acknowledge_only: submit → RmReadReport → notify finder - - demo_acknowledge_then_validate: submit → RmReadReport → RmValidateReport → notify - - demo_acknowledge_then_invalidate: submit → RmReadReport → RmInvalidateReport → notify + - demo_acknowledge_only: submit → RmReadReportActivity → notify finder + - demo_acknowledge_then_validate: submit → RmReadReportActivity → RmValidateReportActivity → notify + - demo_acknowledge_then_invalidate: submit → RmReadReportActivity → RmInvalidateReportActivity → notify """ import logging diff --git a/test/demo/test_find_case_by_report.py b/test/demo/test_find_case_by_report.py index 1ab1e478..e2a48aa4 100644 --- a/test/demo/test_find_case_by_report.py +++ b/test/demo/test_find_case_by_report.py @@ -14,8 +14,10 @@ from unittest.mock import Mock -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.demo.receive_report_demo import find_case_by_report diff --git a/test/demo/test_initialize_case_demo.py b/test/demo/test_initialize_case_demo.py index e4a6e219..4178bc3b 100644 --- a/test/demo/test_initialize_case_demo.py +++ b/test/demo/test_initialize_case_demo.py @@ -41,11 +41,11 @@ def test_demo(demo_env, caplog): Verifies the full case initialization sequence: - Report submitted and validated - - Case created with CreateCase + - Case created with CreateCaseActivity - Vendor (case creator) added as VendorParticipant before finder - - Report linked via AddReportToCase - - Finder participant created via CreateParticipant - - Finder participant added via AddParticipantToCase + - Report linked via AddReportToCaseActivity + - Finder participant created via CreateParticipantActivity + - Finder participant added via AddParticipantToCaseActivity - No errors logged during execution """ import logging diff --git a/test/demo/test_receive_report_demo_bug.py b/test/demo/test_receive_report_demo_bug.py index db387d0a..cc8da4a4 100644 --- a/test/demo/test_receive_report_demo_bug.py +++ b/test/demo/test_receive_report_demo_bug.py @@ -14,9 +14,11 @@ The demo code needs to handle all these cases. """ -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.collections import as_OrderedCollection -from vultron.as_vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.collections import ( + as_OrderedCollection, +) +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity def test_inbox_items_can_be_strings(): diff --git a/test/test_behavior_dispatcher.py b/test/test_behavior_dispatcher.py index 928fc3fa..f029ba02 100644 --- a/test/test_behavior_dispatcher.py +++ b/test/test_behavior_dispatcher.py @@ -1,71 +1,41 @@ import logging -from vultron import behavior_dispatcher as bd -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.enums import as_TransitiveActivityType - -MessageSemantics = bd.MessageSemantics - - -def test_prepare_for_dispatch_parses_activity_and_constructs_dispatchactivity( - monkeypatch, -): - """prepare_for_dispatch should parse the passed activity and let pydantic construct the payload model.""" - # keep semantics resolution deterministic - monkeypatch.setattr( - bd, - "find_matching_semantics", - lambda activity: MessageSemantics.UNKNOWN, - ) - - mapping_activity = as_Create( - as_id="act-123", actor="actor-1", object="obj-1" - ) - dispatch_msg = bd.prepare_for_dispatch(mapping_activity) +from unittest.mock import MagicMock - assert dispatch_msg.semantic_type == MessageSemantics.UNKNOWN - assert dispatch_msg.activity_id == "act-123" - - # payload should be a real as_Activity instance - assert isinstance(dispatch_msg.payload, bd.as_Activity) - # and retain the expected field values - assert getattr(dispatch_msg.payload, "as_id", None) == "act-123" - assert ( - getattr(dispatch_msg.payload, "as_type", None) - == as_TransitiveActivityType.CREATE - ) +from vultron import behavior_dispatcher as bd +from vultron.core.models.events import ( + CreateReportReceivedEvent, + MessageSemantics, +) def test_get_dispatcher_returns_local_dispatcher(): """get_dispatcher should return an object implementing dispatch().""" - dispatcher = bd.get_dispatcher() + from unittest.mock import MagicMock + + dispatcher = bd.get_dispatcher(handler_map={}, dl=MagicMock()) assert hasattr(dispatcher, "dispatch") and callable(dispatcher.dispatch) def test_local_dispatcher_dispatch_logs_payload(caplog): """ DirectActivityDispatcher.dispatch should log an info message about dispatching and a debug - message containing the activity dump (ensure the activity id appears in the debug output). + message containing the activity id. """ caplog.set_level(logging.DEBUG) - dispatcher = bd.DirectActivityDispatcher() - - # Create a proper VulnerabilityReport and Create activity - report = VulnerabilityReport( - name="TEST-REPORT-001", content="Test vulnerability report" - ) - activity = as_Create( - as_id="act-xyz", - actor="https://example.org/users/tester", - object=report, + mock_dl = MagicMock() + dispatcher = bd.DirectActivityDispatcher( + handler_map={MessageSemantics.CREATE_REPORT: MagicMock()}, dl=mock_dl ) - # Construct a DispatchActivity using a real as_Activity payload - # Use CREATE_REPORT semantics to match the activity structure - dispatchable = bd.DispatchActivity( + # Construct a DispatchEvent directly with a typed domain event (no AS2 construction needed) + dispatchable = bd.DispatchEvent( semantic_type=MessageSemantics.CREATE_REPORT, - activity_id=activity.as_id, - payload=activity, + activity_id="act-xyz", + payload=CreateReportReceivedEvent( + activity_id="act-xyz", + actor_id="https://example.org/users/tester", + object_type="VulnerabilityReport", + ), ) dispatcher.dispatch(dispatchable) @@ -78,5 +48,5 @@ def test_local_dispatcher_dispatch_logs_payload(caplog): ] assert any("Dispatching activity" in m for m in info_msgs) - # debug should include the activity id produced by model_dump_json + # debug should include the activity id assert any("act-xyz" in m for m in debug_msgs) diff --git a/test/test_semantic_activity_patterns.py b/test/test_semantic_activity_patterns.py index 15db2fb2..63f59529 100644 --- a/test/test_semantic_activity_patterns.py +++ b/test/test_semantic_activity_patterns.py @@ -1,9 +1,11 @@ from typing import Any, Dict, Iterable import itertools -from vultron.activity_patterns import ActivityPattern -from vultron.enums import MessageSemantics -from vultron.semantic_map import SEMANTICS_ACTIVITY_PATTERNS +from vultron.core.models.events import MessageSemantics +from vultron.wire.as2.extractor import ( + ActivityPattern, + SEMANTICS_ACTIVITY_PATTERNS, +) def test_all_message_semantics_have_activity_patterns(): diff --git a/test/test_semantic_handler_map.py b/test/test_semantic_handler_map.py index 9afab6ef..9dde5cd5 100644 --- a/test/test_semantic_handler_map.py +++ b/test/test_semantic_handler_map.py @@ -1,12 +1,12 @@ -from vultron.enums import MessageSemantics -from vultron.semantic_handler_map import get_semantics_handlers +from vultron.core.models.events import MessageSemantics +from vultron.api.v2.backend.handler_map import SEMANTICS_HANDLERS def test_all_message_semantics_have_handlers(): - """Ensure every MessageSemantics member is present as a key in the semantics handler map.""" - handler_map = get_semantics_handlers() + """Ensure every MessageSemantics member is present as a key in SEMANTICS_HANDLERS.""" missing = [ - member for member in MessageSemantics if member not in handler_map + member + for member in MessageSemantics + if member not in SEMANTICS_HANDLERS ] - assert not missing, f"Missing handlers for semantics: {missing}" diff --git a/test/wire/__init__.py b/test/wire/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/wire/as2/__init__.py b/test/wire/as2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/wire/as2/test_extractor.py b/test/wire/as2/test_extractor.py new file mode 100644 index 00000000..fd539239 --- /dev/null +++ b/test/wire/as2/test_extractor.py @@ -0,0 +1,278 @@ +"""Tests for vultron.wire.as2.extractor.""" + +import pytest +from datetime import datetime, timezone + +from vultron.core.models.events import MessageSemantics +from vultron.wire.as2.extractor import ( + SEMANTICS_ACTIVITY_PATTERNS, + ActivityPattern, + find_matching_semantics, + extract_intent, +) + + +def test_find_matching_semantics_returns_unknown_for_unmatched_activity(): + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + + # Create + Actor has no matching pattern (conservative string matching + # means only explicit typed objects trigger pattern skips) + actor = as_Actor(name="test-actor") + activity = as_Create( + actor="https://example.org/alice", + object=actor, + ) + result = find_matching_semantics(activity) + assert result == MessageSemantics.UNKNOWN + + +def test_find_matching_semantics_returns_correct_semantics_for_create_report(): + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, + ) + + report = VulnerabilityReport(name="VR-001", content="test report") + activity = as_Create( + actor="https://example.org/finder", + object=report, + ) + result = find_matching_semantics(activity) + assert result == MessageSemantics.CREATE_REPORT + + +def test_all_message_semantics_except_unknown_have_patterns(): + missing = [ + m + for m in MessageSemantics + if m != MessageSemantics.UNKNOWN + and m not in SEMANTICS_ACTIVITY_PATTERNS + ] + assert not missing, f"Missing patterns for: {missing}" + + +def test_activity_pattern_match_returns_false_for_wrong_activity_type(): + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.enums import ( + as_TransitiveActivityType as TAtype, + as_ObjectType as AOtype, + ) + + pattern = ActivityPattern(activity_=TAtype.ADD, object_=AOtype.NOTE) + activity = as_Create( + actor="https://example.org/alice", + object="https://example.org/notes/1", + ) + assert not pattern.match(activity) + + +# --- wire-to-domain round-trip tests for new fields --- + + +def test_extract_intent_report_pass_through_fields(): + """New VultronReport fields (summary, url, media_type, published, updated) survive extraction.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, + ) + + now = datetime.now(timezone.utc) + report = VulnerabilityReport( + name="VR-001", + summary="Brief summary", + content="Full content", + url="https://example.org/reports/vr-001", + media_type="text/plain", + attributed_to="https://example.org/alice", + context="https://example.org/cases/1", + published=now, + updated=now, + ) + activity = as_Create(actor="https://example.org/alice", object=report) + event = extract_intent(activity) + + r = event.report + assert r is not None + assert r.summary == "Brief summary" + assert r.url == "https://example.org/reports/vr-001" + assert r.media_type == "text/plain" + assert r.published == now + assert r.updated == now + + +def test_extract_intent_case_pass_through_fields(): + """New VultronCase fields (published, updated) survive extraction.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + now = datetime.now(timezone.utc) + case = VulnerabilityCase( + name="CASE-001", + summary="Case summary", + published=now, + updated=now, + ) + activity = as_Create(actor="https://example.org/alice", object=case) + event = extract_intent(activity) + + c = event.case + assert c is not None + assert c.published == now + assert c.updated == now + + +def test_extract_intent_embargo_pass_through_fields(): + """New VultronEmbargoEvent fields (published, updated) survive extraction.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + + now = datetime.now(timezone.utc) + embargo = EmbargoEvent( + context="https://example.org/cases/1", + published=now, + updated=now, + ) + # CreateEmbargoEvent pattern: Create + EVENT + context=VULNERABILITY_CASE + activity = as_Create( + actor="https://example.org/alice", + object=embargo, + context="https://example.org/cases/1", + ) + event = extract_intent(activity) + + e = event.embargo + assert e is not None + assert e.published == now + assert e.updated == now + + +def test_extract_intent_note_pass_through_fields(): + """New VultronNote fields (summary, url) survive extraction.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.base.objects.object_types import as_Note + + note = as_Note( + name="Note title", + summary="Note summary", + content="Note body", + url="https://example.org/notes/1", + attributed_to="https://example.org/alice", + context="https://example.org/cases/1", + ) + activity = as_Create(actor="https://example.org/alice", object=note) + event = extract_intent(activity) + + n = event.note + assert n is not None + assert n.summary == "Note summary" + assert n.url == "https://example.org/notes/1" + + +def test_extract_intent_activity_origin_field(): + """New VultronActivity.origin field is populated from the wire activity.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, + ) + + report = VulnerabilityReport(name="VR-001", content="test") + activity = as_Create( + actor="https://example.org/alice", + object=report, + origin="https://example.org/cases/original", + ) + event = extract_intent(activity) + + assert event.activity is not None + assert event.activity.origin == "https://example.org/cases/original" + + +def test_extract_intent_participant_case_roles(): + """VultronParticipant.case_roles is populated from the wire CaseParticipant.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.bt.roles.states import CVDRoles + + participant = CaseParticipant( + attributed_to="https://example.org/alice", + context="https://example.org/cases/1", + ) + participant.case_roles = [CVDRoles.VENDOR] + # CreateCaseParticipant pattern: Create + CASE_PARTICIPANT + context=VULNERABILITY_CASE + activity = as_Create( + actor="https://example.org/alice", + object=participant, + context="https://example.org/cases/1", + ) + event = extract_intent(activity) + + p = event.participant + assert p is not None + assert CVDRoles.VENDOR in p.case_roles + + +def test_extract_intent_case_status_name(): + """VultronCaseStatus.name is populated from the wire CaseStatus.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.case_status import CaseStatus + + cs = CaseStatus(context="https://example.org/cases/1") + # CreateCaseStatusActivity pattern: Create + CASE_STATUS + context=VULNERABILITY_CASE + activity = as_Create( + actor="https://example.org/alice", + object=cs, + context="https://example.org/cases/1", + ) + event = extract_intent(activity) + + s = event.status + assert s is not None + assert s.name == cs.name + + +def test_extract_intent_participant_status_vfd_state(): + """VultronParticipantStatus.vfd_state is populated from the wire ParticipantStatus.""" + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus + from vultron.case_states.states import CS_vfd + + ps = ParticipantStatus( + context="https://example.org/cases/1", + vfd_state=CS_vfd.Vfd, + ) + activity = as_Create( + actor="https://example.org/alice", + object=ps, + ) + event = extract_intent(activity) + + s = event.status + assert s is not None + assert s.vfd_state == CS_vfd.Vfd diff --git a/test/wire/as2/test_parser.py b/test/wire/as2/test_parser.py new file mode 100644 index 00000000..354f7be4 --- /dev/null +++ b/test/wire/as2/test_parser.py @@ -0,0 +1,53 @@ +"""Tests for vultron.wire.as2.parser.""" + +import pytest + +from vultron.wire.as2.errors import ( + VultronParseMissingTypeError, + VultronParseUnknownTypeError, + VultronParseValidationError, +) +from vultron.wire.as2.parser import parse_activity + + +def test_parse_activity_raises_missing_type_when_type_absent(): + with pytest.raises(VultronParseMissingTypeError): + parse_activity({}) + + +def test_parse_activity_raises_unknown_type_for_unrecognized_type(): + with pytest.raises(VultronParseUnknownTypeError): + parse_activity({"type": "NoSuchActivityType"}) + + +def test_parse_activity_raises_validation_error_for_invalid_data(monkeypatch): + from vultron.wire.as2 import parser as p + from types import SimpleNamespace + + class _FailingModel: + @staticmethod + def model_validate(data): + raise ValueError("bad data") + + monkeypatch.setattr( + p, + "VOCABULARY", + SimpleNamespace(activities={"Create": _FailingModel}), + ) + with pytest.raises(VultronParseValidationError): + parse_activity({"type": "Create"}) + + +def test_parse_activity_returns_typed_activity_for_valid_create(): + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) + + result = parse_activity( + { + "type": "Create", + "actor": "https://example.org/alice", + "object": "https://example.org/notes/1", + } + ) + assert isinstance(result, as_Create) diff --git a/test/as_vocab/__init__.py b/test/wire/as2/vocab/__init__.py similarity index 100% rename from test/as_vocab/__init__.py rename to test/wire/as2/vocab/__init__.py diff --git a/test/as_vocab/test_actvitities/__init__.py b/test/wire/as2/vocab/test_actvitities/__init__.py similarity index 100% rename from test/as_vocab/test_actvitities/__init__.py rename to test/wire/as2/vocab/test_actvitities/__init__.py diff --git a/test/as_vocab/test_actvitities/test_activities.py b/test/wire/as2/vocab/test_actvitities/test_activities.py similarity index 87% rename from test/as_vocab/test_actvitities/test_activities.py rename to test/wire/as2/vocab/test_actvitities/test_activities.py index 3af14288..611b4847 100644 --- a/test/as_vocab/test_actvitities/test_activities.py +++ b/test/wire/as2/vocab/test_actvitities/test_activities.py @@ -13,9 +13,11 @@ import unittest -import vultron.as_vocab.activities as activities # noqa: F401 -from vultron.as_vocab.activities.case_participant import CreateParticipant -from vultron.as_vocab.objects.case_participant import VendorParticipant +import vultron.wire.as2.vocab.activities as activities # noqa: F401 +from vultron.wire.as2.vocab.activities.case_participant import ( + CreateParticipantActivity, +) +from vultron.wire.as2.vocab.objects.case_participant import VendorParticipant class MyTestCase(unittest.TestCase): @@ -30,7 +32,7 @@ def test_something(self): class TestCreateParticipantName(unittest.TestCase): - """CreateParticipant activity name should clearly identify CaseParticipant creation.""" + """CreateParticipantActivity activity name should clearly identify CaseParticipant creation.""" def setUp(self): self.actor_id = "https://vultron.example/organizations/vendorco" @@ -41,7 +43,7 @@ def setUp(self): attributed_to=self.attributed_to, context=self.case_id, ) - self.activity = CreateParticipant( + self.activity = CreateParticipantActivity( actor=self.actor_id, as_object=self.participant, context=self.case_id, diff --git a/test/as_vocab/test_actvitities/test_actor.py b/test/wire/as2/vocab/test_actvitities/test_actor.py similarity index 88% rename from test/as_vocab/test_actvitities/test_actor.py rename to test/wire/as2/vocab/test_actvitities/test_actor.py index 93e1e0b8..f7c39ada 100644 --- a/test/as_vocab/test_actvitities/test_actor.py +++ b/test/wire/as2/vocab/test_actvitities/test_actor.py @@ -13,22 +13,22 @@ import unittest from typing import Type -import vultron.as_vocab.activities.actor as actor # noqa: F401 -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.activities.transitive import ( +import vultron.wire.as2.vocab.activities.actor as actor # noqa: F401 +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Accept, as_Offer, as_Reject, as_TransitiveActivity, ) -from vultron.as_vocab.base.objects.actors import ( +from vultron.wire.as2.vocab.base.objects.actors import ( as_Application, as_Group, as_Organization, as_Person, as_Service, ) -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase ACTOR_CLASSES = [ as_Application, @@ -41,13 +41,13 @@ class MyTestCase(unittest.TestCase): def test_recommend_actor(self): - cls = actor.RecommendActor + cls = actor.RecommendActorActivity expect_class = as_Offer expect_type = "Offer" self._test_base_actor_activity(cls, expect_class, expect_type) def test_accept_actor_recommendation(self): - cls = actor.AcceptActorRecommendation + cls = actor.AcceptActorRecommendationActivity expect_class = as_Accept expect_type = "Accept" self._test_accept_reject_actor_recommendation( @@ -55,7 +55,7 @@ def test_accept_actor_recommendation(self): ) def test_reject_actor_recommendation(self): - cls = actor.RejectActorRecommendation + cls = actor.RejectActorRecommendationActivity expect_class = as_Reject expect_type = "Reject" self._test_accept_reject_actor_recommendation( @@ -71,7 +71,7 @@ def _test_accept_reject_actor_recommendation( for actor_class in ACTOR_CLASSES: _actor = actor_class(name=actor_class.__name__) _case = VulnerabilityCase(name=f"{actor_class.__name__} Case") - _recommendation = actor.RecommendActor( + _recommendation = actor.RecommendActorActivity( actor=_actor, object=_actor, target=_case ) _object = cls(actor=_actor, object=_recommendation, target=_case) @@ -81,7 +81,9 @@ def _test_accept_reject_actor_recommendation( self.assertIsInstance(_object, expect_class) self.assertIsInstance(_object, cls) # check the _object of the activity is a RecommendActor - self.assertIsInstance(_object.as_object, actor.RecommendActor) + self.assertIsInstance( + _object.as_object, actor.RecommendActorActivity + ) # check the target of the activity is correct instance self.assertEqual(_object.target, _case) # check the actor of the activity is correct instance diff --git a/test/wire/as2/vocab/test_base_utils.py b/test/wire/as2/vocab/test_base_utils.py new file mode 100644 index 00000000..5f117d34 --- /dev/null +++ b/test/wire/as2/vocab/test_base_utils.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Tests for vultron.wire.as2.vocab.base.utils — OID-01-001 through OID-01-004. +""" + +import re +import uuid + +import pytest + +from vultron.wire.as2.vocab.base import utils +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.utils import URN_UUID_PREFIX, generate_new_id + +_UUID_PATTERN = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, +) + + +class TestGenerateNewId: + """Tests for generate_new_id() — OID-01-001.""" + + def test_default_returns_urn_uuid_prefix(self): + id_ = generate_new_id() + assert id_.startswith(URN_UUID_PREFIX) + + def test_default_id_is_valid_urn_uuid(self): + id_ = generate_new_id() + uuid_part = id_.removeprefix(URN_UUID_PREFIX) + assert _UUID_PATTERN.fullmatch(uuid_part) is not None + + def test_default_id_is_not_bare_uuid(self): + id_ = generate_new_id() + assert not _UUID_PATTERN.fullmatch( + id_ + ), "generate_new_id() must not return a bare UUID" + + def test_prefix_appends_uuid(self): + prefix = "https://example.org/objects" + id_ = generate_new_id(prefix=prefix) + assert id_.startswith(prefix + "/") + uuid_part = id_.split("/")[-1] + assert _UUID_PATTERN.fullmatch(uuid_part) is not None + + def test_each_call_returns_unique_id(self): + ids = {generate_new_id() for _ in range(100)} + assert len(ids) == 100 + + +class TestAsBaseDefaultId: + """Tests that as_Base objects receive URI-form IDs by default — OID-01-001.""" + + def test_new_object_has_uri_form_id(self): + obj = as_Base() + assert obj.as_id.startswith(URN_UUID_PREFIX) or "://" in obj.as_id + + def test_new_object_id_is_not_bare_uuid(self): + obj = as_Base() + assert not _UUID_PATTERN.fullmatch( + obj.as_id + ), "as_id must not be a bare UUID" + + def test_two_objects_have_different_ids(self): + obj1 = as_Base() + obj2 = as_Base() + assert obj1.as_id != obj2.as_id + + def test_explicit_https_id_is_accepted(self): + explicit_id = "https://example.org/objects/abc123" + obj = as_Base(as_id=explicit_id) + assert obj.as_id == explicit_id + + def test_explicit_urn_uuid_id_is_accepted(self): + explicit_id = f"urn:uuid:{uuid.uuid4()}" + obj = as_Base(as_id=explicit_id) + assert obj.as_id == explicit_id diff --git a/test/as_vocab/test_case_event.py b/test/wire/as2/vocab/test_case_event.py similarity index 93% rename from test/as_vocab/test_case_event.py rename to test/wire/as2/vocab/test_case_event.py index ef58e1a5..c30956ed 100644 --- a/test/as_vocab/test_case_event.py +++ b/test/wire/as2/vocab/test_case_event.py @@ -23,10 +23,10 @@ import pytest from pydantic import ValidationError -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.as_vocab.objects.case_event import CaseEvent -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.adapters.driven.db_record import object_to_record +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.wire.as2.vocab.objects.case_event import CaseEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase OBJ_ID = "https://example.org/reports/abc123" EVENT_TYPE = "embargo_accepted" @@ -67,22 +67,22 @@ def test_event_type_required(self): def test_object_id_empty_string_rejected(self): with pytest.raises(ValidationError) as exc_info: CaseEvent(object_id="", event_type=EVENT_TYPE) - assert "object_id must be a non-empty string" in str(exc_info.value) + assert "must be a non-empty string" in str(exc_info.value) def test_object_id_whitespace_only_rejected(self): with pytest.raises(ValidationError) as exc_info: CaseEvent(object_id=" ", event_type=EVENT_TYPE) - assert "object_id must be a non-empty string" in str(exc_info.value) + assert "must be a non-empty string" in str(exc_info.value) def test_event_type_empty_string_rejected(self): with pytest.raises(ValidationError) as exc_info: CaseEvent(object_id=OBJ_ID, event_type="") - assert "event_type must be a non-empty string" in str(exc_info.value) + assert "must be a non-empty string" in str(exc_info.value) def test_event_type_whitespace_only_rejected(self): with pytest.raises(ValidationError) as exc_info: CaseEvent(object_id=OBJ_ID, event_type=" ") - assert "event_type must be a non-empty string" in str(exc_info.value) + assert "must be a non-empty string" in str(exc_info.value) class TestCaseEventSerialization(unittest.TestCase): diff --git a/test/as_vocab/test_case_participant.py b/test/wire/as2/vocab/test_case_participant.py similarity index 67% rename from test/as_vocab/test_case_participant.py rename to test/wire/as2/vocab/test_case_participant.py index 9f0eac86..8498559e 100644 --- a/test/as_vocab/test_case_participant.py +++ b/test/wire/as2/vocab/test_case_participant.py @@ -17,11 +17,14 @@ import unittest -from vultron.api.v2.datalayer.db_record import ( +import pytest +from pydantic import ValidationError + +from vultron.adapters.driven.db_record import ( object_to_record, record_to_object, ) -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.objects.case_participant import ( CaseParticipant, CoordinatorParticipant, FinderParticipant, @@ -135,5 +138,69 @@ def test_accepted_embargo_ids_subclass_round_trip(self): ) +class TestCaseParticipantNameField(unittest.TestCase): + """Tests for CaseParticipant.name field empty-string validation (CS-08-001).""" + + def setUp(self): + self.actor_id = "https://example.org/actors/alice" + self.case_id = "https://example.org/cases/case-001" + + def test_name_none_accepted(self): + """name=None is valid when attributed_to is also not set.""" + participant = CaseParticipant(context=self.case_id, name=None) + self.assertIsNone(participant.name) + + def test_name_non_empty_accepted(self): + """name with a non-empty string is valid.""" + participant = CaseParticipant( + attributed_to=self.actor_id, context=self.case_id, name="Alice" + ) + self.assertEqual("Alice", participant.name) + + def test_name_empty_string_rejected(self): + """name must not be an empty string (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + CaseParticipant( + attributed_to=self.actor_id, context=self.case_id, name="" + ) + assert "must be a non-empty string" in str(exc_info.value) + + def test_name_whitespace_only_rejected(self): + """name must not be whitespace-only (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + CaseParticipant( + attributed_to=self.actor_id, context=self.case_id, name=" " + ) + assert "must be a non-empty string" in str(exc_info.value) + + def test_participant_case_name_none_accepted(self): + """participant_case_name=None is valid.""" + participant = CaseParticipant( + attributed_to=self.actor_id, + context=self.case_id, + participant_case_name=None, + ) + self.assertIsNone(participant.participant_case_name) + + def test_participant_case_name_non_empty_accepted(self): + """participant_case_name with a non-empty string is valid.""" + participant = CaseParticipant( + attributed_to=self.actor_id, + context=self.case_id, + participant_case_name="My Case", + ) + self.assertEqual("My Case", participant.participant_case_name) + + def test_participant_case_name_empty_string_rejected(self): + """participant_case_name must not be an empty string (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + CaseParticipant( + attributed_to=self.actor_id, + context=self.case_id, + participant_case_name="", + ) + assert "must be a non-empty string" in str(exc_info.value) + + if __name__ == "__main__": unittest.main() diff --git a/test/as_vocab/test_case_reference.py b/test/wire/as2/vocab/test_case_reference.py similarity index 94% rename from test/as_vocab/test_case_reference.py rename to test/wire/as2/vocab/test_case_reference.py index 6738b1f6..3d16c849 100644 --- a/test/as_vocab/test_case_reference.py +++ b/test/wire/as2/vocab/test_case_reference.py @@ -16,8 +16,8 @@ import pytest from pydantic import ValidationError -import vultron.as_vocab.objects.case_reference as cr -from vultron.enums import VultronObjectType as VO_type +import vultron.wire.as2.vocab.objects.case_reference as cr +from vultron.core.models.enums import VultronObjectType as VO_type class TestCaseReference(unittest.TestCase): @@ -51,7 +51,7 @@ def test_case_reference_url_not_empty(self): with pytest.raises(ValidationError) as exc_info: cr.CaseReference(url="") - assert "url must be a non-empty string" in str(exc_info.value) + assert "must be a non-empty string" in str(exc_info.value) def test_case_reference_url_only(self): """Test CaseReference with only url field.""" @@ -70,9 +70,7 @@ def test_case_reference_name_not_empty(self): with pytest.raises(ValidationError) as exc_info: cr.CaseReference(url="https://example.org/", name="") - assert "name must be either None or a non-empty string" in str( - exc_info.value - ) + assert "must be a non-empty string" in str(exc_info.value) def test_case_reference_name_with_url(self): """Test CaseReference with name.""" @@ -168,7 +166,7 @@ def test_case_reference_is_vultron_object_type(self): def test_case_reference_separate_from_report(self): """Test that CaseReference is a distinct type.""" - from vultron.as_vocab.objects.vulnerability_report import ( + from vultron.wire.as2.vocab.objects.vulnerability_report import ( VulnerabilityReport, ) @@ -180,7 +178,7 @@ def test_case_reference_separate_from_report(self): def test_case_reference_separate_from_vulnerability_record(self): """Test that CaseReference is distinct from VulnerabilityRecord.""" - from vultron.as_vocab.objects.vulnerability_record import ( + from vultron.wire.as2.vocab.objects.vulnerability_record import ( VulnerabilityRecord, ) diff --git a/test/wire/as2/vocab/test_case_status.py b/test/wire/as2/vocab/test_case_status.py new file mode 100644 index 00000000..d9909167 --- /dev/null +++ b/test/wire/as2/vocab/test_case_status.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +""" +Tests for CaseStatus and ParticipantStatus empty-string field validation +(CS-08-001). +""" + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +import unittest + +import pytest +from pydantic import ValidationError + +from vultron.wire.as2.vocab.objects.case_status import ( + CaseStatus, + ParticipantStatus, +) + +CASE_ID = "https://example.org/cases/case-001" +ACTOR_ID = "https://example.org/actors/alice" + + +class TestCaseStatusContextField(unittest.TestCase): + """Tests for CaseStatus.context empty-string validation (CS-08-001).""" + + def test_context_none_accepted(self): + """context=None is valid (optional field).""" + cs = CaseStatus(context=None) + self.assertIsNone(cs.context) + + def test_context_non_empty_accepted(self): + """context with a non-empty string (case ID) is valid.""" + cs = CaseStatus(context=CASE_ID) + self.assertEqual(CASE_ID, cs.context) + + def test_context_empty_string_rejected(self): + """context must not be an empty string (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + CaseStatus(context="") + assert "must be a non-empty string" in str(exc_info.value) + + def test_context_whitespace_only_rejected(self): + """context must not be whitespace-only (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + CaseStatus(context=" ") + assert "must be a non-empty string" in str(exc_info.value) + + +class TestParticipantStatusTrackingIdField(unittest.TestCase): + """Tests for ParticipantStatus.tracking_id empty-string validation (CS-08-001).""" + + def test_tracking_id_none_accepted(self): + """tracking_id=None is valid (optional field).""" + ps = ParticipantStatus( + attributed_to=ACTOR_ID, context=CASE_ID, tracking_id=None + ) + self.assertIsNone(ps.tracking_id) + + def test_tracking_id_non_empty_accepted(self): + """tracking_id with a non-empty string is valid.""" + ps = ParticipantStatus( + attributed_to=ACTOR_ID, context=CASE_ID, tracking_id="TICKET-123" + ) + self.assertEqual("TICKET-123", ps.tracking_id) + + def test_tracking_id_empty_string_rejected(self): + """tracking_id must not be an empty string (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + ParticipantStatus( + attributed_to=ACTOR_ID, context=CASE_ID, tracking_id="" + ) + assert "must be a non-empty string" in str(exc_info.value) + + def test_tracking_id_whitespace_only_rejected(self): + """tracking_id must not be whitespace-only (CS-08-001).""" + with pytest.raises(ValidationError) as exc_info: + ParticipantStatus( + attributed_to=ACTOR_ID, context=CASE_ID, tracking_id=" " + ) + assert "must be a non-empty string" in str(exc_info.value) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/as_vocab/test_create_activity.py b/test/wire/as2/vocab/test_create_activity.py similarity index 95% rename from test/as_vocab/test_create_activity.py rename to test/wire/as2/vocab/test_create_activity.py index 2d638574..939eccb3 100644 --- a/test/as_vocab/test_create_activity.py +++ b/test/wire/as2/vocab/test_create_activity.py @@ -13,7 +13,7 @@ import unittest -from vultron.as_vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create class MyTestCase(unittest.TestCase): diff --git a/test/as_vocab/test_embargo_policy.py b/test/wire/as2/vocab/test_embargo_policy.py similarity index 95% rename from test/as_vocab/test_embargo_policy.py rename to test/wire/as2/vocab/test_embargo_policy.py index d9624b6a..271dd27d 100644 --- a/test/as_vocab/test_embargo_policy.py +++ b/test/wire/as2/vocab/test_embargo_policy.py @@ -20,10 +20,10 @@ import pytest from pydantic import ValidationError -import vultron.as_vocab.objects.embargo_policy as ep_module -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.api.v2.datalayer.tinydb_backend import TinyDbDataLayer -from vultron.enums import VultronObjectType as VO_type +import vultron.wire.as2.vocab.objects.embargo_policy as ep_module +from vultron.adapters.driven.db_record import object_to_record +from vultron.adapters.driven.datalayer_tinydb import TinyDbDataLayer +from vultron.core.models.enums import VultronObjectType as VO_type ACTOR_ID = "https://example.org/actors/vendor" INBOX = "https://example.org/actors/vendor/inbox" @@ -201,7 +201,7 @@ def test_json_serialization(self): self.assertIn(INBOX, j) def test_type_distinctness(self): - from vultron.as_vocab.objects.vulnerability_case import ( + from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCase, ) diff --git a/test/as_vocab/test_vocab_examples.py b/test/wire/as2/vocab/test_vocab_examples.py similarity index 95% rename from test/as_vocab/test_vocab_examples.py rename to test/wire/as2/vocab/test_vocab_examples.py index b71ce2a2..2aaec22d 100644 --- a/test/as_vocab/test_vocab_examples.py +++ b/test/wire/as2/vocab/test_vocab_examples.py @@ -17,11 +17,13 @@ import unittest from typing import Sequence -import vultron.as_vocab.examples.vocab_examples as examples -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.activities.intransitive import as_Question -from vultron.as_vocab.base.objects.activities.transitive import ( +import vultron.wire.as2.vocab.examples.vocab_examples as examples +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.activities.intransitive import ( + as_Question, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Accept, as_Add, as_Announce, @@ -38,13 +40,21 @@ as_Update, as_TentativeReject, ) -from vultron.as_vocab.base.objects.actors import as_Actor, as_Organization -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.base.objects.object_types import as_Event, as_Note -from vultron.as_vocab.objects.case_participant import CaseParticipant -from vultron.as_vocab.objects.case_status import CaseStatus, ParticipantStatus -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.base.objects.actors import ( + as_Actor, + as_Organization, +) +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.objects.object_types import as_Event, as_Note +from vultron.wire.as2.vocab.objects.case_participant import CaseParticipant +from vultron.wire.as2.vocab.objects.case_status import ( + CaseStatus, + ParticipantStatus, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.bt.embargo_management.states import EM from vultron.bt.report_management.states import RM from vultron.case_states.states import CS_pxa, CS_vfd @@ -480,10 +490,12 @@ def test_accept_actor_recommendation(self): self.assertEqual(activity.actor, vendor.as_id) self.assertEqual(activity.context, case.as_id) - # as_object is now the RecommendActor offer, not the coordinator ID - from vultron.as_vocab.activities.actor import RecommendActor + # as_object is now the RecommendActorActivity offer, not the coordinator ID + from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, + ) - self.assertIsInstance(activity.as_object, RecommendActor) + self.assertIsInstance(activity.as_object, RecommendActorActivity) self.assertEqual(activity.target, case.as_id) self.assertEqual(activity.to, finder.as_id) @@ -500,10 +512,12 @@ def test_reject_actor_recommendation(self): self.assertEqual(activity.actor, vendor.as_id) self.assertEqual(activity.context, case.as_id) - # as_object is now the RecommendActor offer, not the coordinator ID - from vultron.as_vocab.activities.actor import RecommendActor + # as_object is now the RecommendActorActivity offer, not the coordinator ID + from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, + ) - self.assertIsInstance(activity.as_object, RecommendActor) + self.assertIsInstance(activity.as_object, RecommendActorActivity) self.assertEqual(activity.target, case.as_id) self.assertEqual(activity.to, finder.as_id) diff --git a/test/wire/as2/vocab/test_vulnerability_case.py b/test/wire/as2/vocab/test_vulnerability_case.py new file mode 100644 index 00000000..a6ae6ad1 --- /dev/null +++ b/test/wire/as2/vocab/test_vulnerability_case.py @@ -0,0 +1,177 @@ +"""Tests for VulnerabilityCase, including set_embargo() bug fix.""" + +from datetime import datetime, timezone + +import pytest + +from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + VendorParticipant, +) +from vultron.wire.as2.vocab.objects.case_status import CaseStatus +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.bt.embargo_management.states import EM + + +class TestVulnerabilityCase: + def test_init_has_one_case_status(self): + case = VulnerabilityCase() + assert len(case.case_statuses) == 1 + + def test_current_status_returns_most_recent(self): + older = CaseStatus(updated=datetime(2025, 1, 1, tzinfo=timezone.utc)) + newer = CaseStatus(updated=datetime(2025, 6, 1, tzinfo=timezone.utc)) + case = VulnerabilityCase() + case.case_statuses = [older, newer] + assert case.current_status is newer + + def test_current_status_handles_none_updated(self): + cs = CaseStatus() + case = VulnerabilityCase() + case.case_statuses = [cs] + # Should return the only element without error + assert case.current_status is cs + + def test_set_embargo_sets_active_embargo(self): + case = VulnerabilityCase() + embargo = EmbargoEvent() + case.set_embargo(embargo) + assert case.active_embargo is embargo + + def test_set_embargo_updates_em_state_on_current_status(self): + case = VulnerabilityCase() + embargo = EmbargoEvent() + # em_state starts as NO_EMBARGO + assert case.current_status.em_state == EM.NO_EMBARGO + case.set_embargo(embargo) + assert case.current_status.em_state == EM.ACTIVE + + def test_set_embargo_updates_most_recent_case_status(self): + older = CaseStatus(updated=datetime(2025, 1, 1, tzinfo=timezone.utc)) + newer = CaseStatus(updated=datetime(2025, 6, 1, tzinfo=timezone.utc)) + case = VulnerabilityCase() + case.case_statuses = [older, newer] + embargo = EmbargoEvent() + case.set_embargo(embargo) + assert newer.em_state == EM.ACTIVE + assert older.em_state == EM.NO_EMBARGO + + +class TestActorParticipantIndex: + """Tests for actor_participant_index consistency (CM-10-002).""" + + def test_index_empty_on_new_case(self): + case = VulnerabilityCase(id="https://example.org/cases/c1") + assert case.actor_participant_index == {} + + def test_add_participant_updates_index(self): + case = VulnerabilityCase(id="https://example.org/cases/c1") + actor_id = "https://example.org/users/alice" + participant = CaseParticipant( + id="https://example.org/cases/c1/participants/alice", + attributed_to=actor_id, + context=case.as_id, + ) + case.add_participant(participant) + assert actor_id in case.actor_participant_index + assert case.actor_participant_index[actor_id] == participant.as_id + + def test_add_participant_appends_to_list(self): + case = VulnerabilityCase(id="https://example.org/cases/c1") + participant = CaseParticipant( + id="https://example.org/cases/c1/participants/alice", + attributed_to="https://example.org/users/alice", + context=case.as_id, + ) + case.add_participant(participant) + assert participant in case.case_participants + + def test_add_participant_with_object_attributed_to(self): + """Index is updated correctly when attributed_to is a full object.""" + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + + case = VulnerabilityCase(id="https://example.org/cases/c2") + actor = as_Actor(id="https://example.org/users/bob", name="Bob") + participant = VendorParticipant( + id="https://example.org/cases/c2/participants/bob", + attributed_to=actor, + context=case.as_id, + ) + case.add_participant(participant) + assert actor.as_id in case.actor_participant_index + assert case.actor_participant_index[actor.as_id] == participant.as_id + + def test_add_multiple_participants_index_grows(self): + case = VulnerabilityCase(id="https://example.org/cases/c3") + for name in ("alice", "bob", "carol"): + p = CaseParticipant( + id=f"https://example.org/cases/c3/participants/{name}", + attributed_to=f"https://example.org/users/{name}", + context=case.as_id, + ) + case.add_participant(p) + assert len(case.actor_participant_index) == 3 + assert len(case.case_participants) == 3 + + def test_remove_participant_removes_from_list_and_index(self): + case = VulnerabilityCase(id="https://example.org/cases/c4") + actor_id = "https://example.org/users/alice" + participant = CaseParticipant( + id="https://example.org/cases/c4/participants/alice", + attributed_to=actor_id, + context=case.as_id, + ) + case.add_participant(participant) + assert actor_id in case.actor_participant_index + + case.remove_participant(participant.as_id) + assert actor_id not in case.actor_participant_index + assert participant not in case.case_participants + + def test_remove_participant_not_in_case_is_safe(self): + case = VulnerabilityCase(id="https://example.org/cases/c5") + case.remove_participant( + "https://example.org/cases/c5/participants/ghost" + ) + assert case.actor_participant_index == {} + assert case.case_participants == [] + + def test_remove_participant_works_with_string_ids_in_list(self): + """remove_participant handles cases where list contains string IDs.""" + case = VulnerabilityCase(id="https://example.org/cases/c6") + participant_id = "https://example.org/cases/c6/participants/alice" + case.case_participants.append(participant_id) + case.actor_participant_index["https://example.org/users/alice"] = ( + participant_id + ) + + case.remove_participant(participant_id) + assert participant_id not in case.case_participants + assert ( + "https://example.org/users/alice" + not in case.actor_participant_index + ) + + def test_index_consistency_after_round_trip(self): + """actor_participant_index survives serialization round-trip.""" + from vultron.adapters.driven.db_record import ( + object_to_record, + record_to_object, + ) + + case = VulnerabilityCase(id="https://example.org/cases/c7") + actor_id = "https://example.org/users/alice" + participant = CaseParticipant( + id="https://example.org/cases/c7/participants/alice", + attributed_to=actor_id, + context=case.as_id, + ) + case.add_participant(participant) + + record = object_to_record(case) + restored = record_to_object(record) + assert hasattr(restored, "actor_participant_index") + assert ( + restored.actor_participant_index.get(actor_id) == participant.as_id + ) diff --git a/test/as_vocab/test_vulnerability_record.py b/test/wire/as2/vocab/test_vulnerability_record.py similarity index 85% rename from test/as_vocab/test_vulnerability_record.py rename to test/wire/as2/vocab/test_vulnerability_record.py index 7e49c8cb..9d45d3d8 100644 --- a/test/as_vocab/test_vulnerability_record.py +++ b/test/wire/as2/vocab/test_vulnerability_record.py @@ -16,8 +16,8 @@ import pytest from pydantic import ValidationError -import vultron.as_vocab.objects.vulnerability_record as vr -from vultron.enums import VultronObjectType as VO_type +import vultron.wire.as2.vocab.objects.vulnerability_record as vr +from vultron.core.models.enums import VultronObjectType as VO_type class TestVulnerabilityRecord(unittest.TestCase): @@ -79,6 +79,18 @@ def test_vulnerability_record_url_optional(self): record = vr.VulnerabilityRecord(name="CVE-2024-1234") self.assertIsNone(record.url) + def test_vulnerability_record_url_not_empty(self): + """Test that url must be non-empty string if provided.""" + with pytest.raises(ValidationError) as exc_info: + vr.VulnerabilityRecord(name="CVE-2024-1234", url="") + assert "must be a non-empty string" in str(exc_info.value) + + def test_vulnerability_record_url_whitespace_rejected(self): + """Test that url cannot be whitespace-only if provided.""" + with pytest.raises(ValidationError) as exc_info: + vr.VulnerabilityRecord(name="CVE-2024-1234", url=" ") + assert "must be a non-empty string" in str(exc_info.value) + def test_vulnerability_record_round_trip(self): """Test serialization and deserialization round-trip.""" record = self.record @@ -110,7 +122,7 @@ def test_vulnerability_record_is_vultron_object_type(self): def test_vulnerability_record_separate_from_report(self): """Test that VulnerabilityRecord is a distinct type from VulnerabilityReport.""" - from vultron.as_vocab.objects.vulnerability_report import ( + from vultron.wire.as2.vocab.objects.vulnerability_report import ( VulnerabilityReport, ) diff --git a/test/as_vocab/test_vulnerability_report.py b/test/wire/as2/vocab/test_vulnerability_report.py similarity index 96% rename from test/as_vocab/test_vulnerability_report.py rename to test/wire/as2/vocab/test_vulnerability_report.py index ec3e9560..429e5000 100644 --- a/test/as_vocab/test_vulnerability_report.py +++ b/test/wire/as2/vocab/test_vulnerability_report.py @@ -14,7 +14,7 @@ import unittest from datetime import datetime -import vultron.as_vocab.objects.vulnerability_report as vr +import vultron.wire.as2.vocab.objects.vulnerability_report as vr class MyTestCase(unittest.TestCase): diff --git a/test/as_vocab/test_vultron_actor.py b/test/wire/as2/vocab/test_vultron_actor.py similarity index 96% rename from test/as_vocab/test_vultron_actor.py rename to test/wire/as2/vocab/test_vultron_actor.py index d56751b5..000ddae1 100644 --- a/test/as_vocab/test_vultron_actor.py +++ b/test/wire/as2/vocab/test_vultron_actor.py @@ -18,9 +18,9 @@ import unittest -from vultron.enums import as_ActorType -from vultron.as_vocab.objects.embargo_policy import EmbargoPolicy -from vultron.as_vocab.objects.vultron_actor import ( +from vultron.wire.as2.enums import as_ActorType +from vultron.wire.as2.vocab.objects.embargo_policy import EmbargoPolicy +from vultron.wire.as2.vocab.objects.vultron_actor import ( VultronActorMixin, VultronOrganization, VultronPerson, diff --git a/uv.lock b/uv.lock index b7c06d8c..b53dea5f 100644 --- a/uv.lock +++ b/uv.lock @@ -1160,6 +1160,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1552,6 +1565,7 @@ dev = [ { name = "mypy" }, { name = "pandas-stubs" }, { name = "pre-commit" }, + { name = "pyright" }, { name = "pytest" }, { name = "types-networkx" }, ] @@ -1593,6 +1607,7 @@ dev = [ { name = "mypy", specifier = ">=1.18.2" }, { name = "pandas-stubs", specifier = ">=2.3.2.250827" }, { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "types-networkx", specifier = ">=3.5.0.20250918" }, ] diff --git a/vultron/activity_patterns.py b/vultron/activity_patterns.py deleted file mode 100644 index 0c645c54..00000000 --- a/vultron/activity_patterns.py +++ /dev/null @@ -1,238 +0,0 @@ -""" -Defines patterns of Activity Streams Activity objects that have specific semantic meaning -in the context of the Vultron protocol. Also provides an ActivityPattern class to represent -and match these patterns. -""" - -from typing import Optional, Union - -from pydantic import BaseModel - -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.enums import ( - VultronObjectType as VOtype, - as_IntransitiveActivityType as IAtype, - as_ObjectType as AOtype, - as_TransitiveActivityType as TAtype, -) - - -class ActivityPattern(BaseModel): - """ - Represents a pattern to match against an activity for behavior dispatching. - Supports nested patterns for wrapped objects. - """ - - description: Optional[str] = None - activity_: TAtype | IAtype - - # Top-level object info (for leaf nodes) - to_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None - object_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None - target_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None - context_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None - in_reply_to_: Optional["ActivityPattern"] = None - - def match(self, activity: as_Activity) -> bool: - """Checks if the given activity matches this pattern.""" - if self.activity_ != activity.as_type: - return False - - def match_field(pattern_field, activity_field) -> bool: - """Helper to match a single field, supporting nested patterns.""" - if pattern_field is None: - return True - # If activity_field is a string (URI/ID reference), we can't match on type - # In this case, we conservatively return True (can't determine match) - if isinstance(activity_field, str): - return True - if isinstance(pattern_field, ActivityPattern): - return pattern_field.match(activity_field) - else: - # Otherwise check if types match - return pattern_field == getattr( - activity_field, "as_type", None - ) - - if not match_field(self.object_, getattr(activity, "as_object", None)): - return False - if not match_field(self.target_, getattr(activity, "target", None)): - return False - if not match_field(self.context_, getattr(activity, "context", None)): - return False - if not match_field(self.to_, getattr(activity, "to", None)): - return False - if not match_field( - self.in_reply_to_, getattr(activity, "in_reply_to", None) - ): - return False - - return True - - -CreateEmbargoEvent = ActivityPattern( - description="Create an embargo event. This is the initial step in the embargo management process, where a coordinator creates an embargo event to manage the embargo on a vulnerability case.", - activity_=TAtype.CREATE, - object_=AOtype.EVENT, - context_=VOtype.VULNERABILITY_CASE, -) -AddEmbargoEventToCase = ActivityPattern( - description="Add an embargo event to a vulnerability case. This is typically observed as an ADD activity where the object is an EVENT and the target is a VULNERABILITY_CASE.", - activity_=TAtype.ADD, - object_=AOtype.EVENT, - target_=VOtype.VULNERABILITY_CASE, -) -RemoveEmbargoEventFromCase = ActivityPattern( - description="Remove an embargo event from a vulnerability case. This is typically observed as a REMOVE activity where the object is an EVENT. The origin field of the activity contains the VulnerabilityCase from which the embargo is removed.", - activity_=TAtype.REMOVE, - object_=AOtype.EVENT, -) -AnnounceEmbargoEventToCase = ActivityPattern( - description="Announce an embargo event to a vulnerability case. This is typically observed as an ANNOUNCE activity where the object is an EVENT and the context is a VULNERABILITY_CASE.", - activity_=TAtype.ANNOUNCE, - object_=AOtype.EVENT, - context_=VOtype.VULNERABILITY_CASE, -) -InviteToEmbargoOnCase = ActivityPattern( - description="Propose an embargo on a vulnerability case. " - "This is observed as an INVITE activity where the object is an EmbargoEvent " - "and the context is the VulnerabilityCase. Corresponds to EmProposeEmbargo.", - activity_=TAtype.INVITE, - object_=AOtype.EVENT, - context_=VOtype.VULNERABILITY_CASE, -) -AcceptInviteToEmbargoOnCase = ActivityPattern( - description="Accept an invitation to an embargo on a vulnerability case.", - activity_=TAtype.ACCEPT, - object_=InviteToEmbargoOnCase, -) -RejectInviteToEmbargoOnCase = ActivityPattern( - description="Reject an invitation to an embargo on a vulnerability case.", - activity_=TAtype.REJECT, - object_=InviteToEmbargoOnCase, -) -CreateReport = ActivityPattern( - description="Create a vulnerability report. This is the initial step in the vulnerability disclosure process, where a finder creates a report to disclose a vulnerability." - " It may not always be observed directly, as it could be implicit in the OFFER of the report.", - activity_=TAtype.CREATE, - object_=VOtype.VULNERABILITY_REPORT, -) -ReportSubmission = ActivityPattern( - description="Submit a vulnerability report for validation. This is typically observed as an OFFER of a VULNERABILITY_REPORT, which represents the submission of the report to a coordinator or vendor for validation.", - activity_=TAtype.OFFER, - object_=VOtype.VULNERABILITY_REPORT, -) -AckReport = ActivityPattern(activity_=TAtype.READ, object_=ReportSubmission) -ValidateReport = ActivityPattern( - activity_=TAtype.ACCEPT, object_=ReportSubmission -) -InvalidateReport = ActivityPattern( - activity_=TAtype.TENTATIVE_REJECT, object_=ReportSubmission -) -CloseReport = ActivityPattern( - activity_=TAtype.REJECT, object_=ReportSubmission -) -CreateCase = ActivityPattern( - activity_=TAtype.CREATE, object_=VOtype.VULNERABILITY_CASE -) -UpdateCase = ActivityPattern( - activity_=TAtype.UPDATE, object_=VOtype.VULNERABILITY_CASE -) -EngageCase = ActivityPattern( - description="Actor engages (joins) a VulnerabilityCase, transitioning their RM state to ACCEPTED.", - activity_=TAtype.JOIN, - object_=VOtype.VULNERABILITY_CASE, -) -DeferCase = ActivityPattern( - description="Actor defers (ignores) a VulnerabilityCase, transitioning their RM state to DEFERRED.", - activity_=TAtype.IGNORE, - object_=VOtype.VULNERABILITY_CASE, -) -AddReportToCase = ActivityPattern( - activity_=TAtype.ADD, - object_=VOtype.VULNERABILITY_REPORT, - target_=VOtype.VULNERABILITY_CASE, -) -SuggestActorToCase = ActivityPattern( - activity_=TAtype.OFFER, - object_=AOtype.ACTOR, - target_=VOtype.VULNERABILITY_CASE, -) -AcceptSuggestActorToCase = ActivityPattern( - activity_=TAtype.ACCEPT, object_=SuggestActorToCase -) -RejectSuggestActorToCase = ActivityPattern( - activity_=TAtype.REJECT, object_=SuggestActorToCase -) -OfferCaseOwnershipTransfer = ActivityPattern( - activity_=TAtype.OFFER, object_=VOtype.VULNERABILITY_CASE -) -AcceptCaseOwnershipTransfer = ActivityPattern( - activity_=TAtype.ACCEPT, object_=OfferCaseOwnershipTransfer -) -RejectCaseOwnershipTransfer = ActivityPattern( - activity_=TAtype.REJECT, object_=OfferCaseOwnershipTransfer -) -InviteActorToCase = ActivityPattern( - activity_=TAtype.INVITE, - target_=VOtype.VULNERABILITY_CASE, -) -AcceptInviteActorToCase = ActivityPattern( - activity_=TAtype.ACCEPT, - object_=InviteActorToCase, -) -RejectInviteActorToCase = ActivityPattern( - activity_=TAtype.REJECT, - object_=InviteActorToCase, -) -CloseCase = ActivityPattern( - activity_=TAtype.LEAVE, object_=VOtype.VULNERABILITY_CASE -) -CreateNote = ActivityPattern( - activity_=TAtype.CREATE, - object_=AOtype.NOTE, -) -AddNoteToCase = ActivityPattern( - activity_=TAtype.ADD, - object_=AOtype.NOTE, - target_=VOtype.VULNERABILITY_CASE, -) -RemoveNoteFromCase = ActivityPattern( - activity_=TAtype.REMOVE, - object_=AOtype.NOTE, - target_=VOtype.VULNERABILITY_CASE, -) -CreateCaseParticipant = ActivityPattern( - activity_=TAtype.CREATE, - object_=VOtype.CASE_PARTICIPANT, - context_=VOtype.VULNERABILITY_CASE, -) -AddCaseParticipantToCase = ActivityPattern( - activity_=TAtype.ADD, - object_=VOtype.CASE_PARTICIPANT, - target_=VOtype.VULNERABILITY_CASE, -) -RemoveCaseParticipantFromCase = ActivityPattern( - activity_=TAtype.REMOVE, - object_=VOtype.CASE_PARTICIPANT, - target_=VOtype.VULNERABILITY_CASE, -) -CreateCaseStatus = ActivityPattern( - activity_=TAtype.CREATE, - object_=VOtype.CASE_STATUS, - context_=VOtype.VULNERABILITY_CASE, -) -AddCaseStatusToCase = ActivityPattern( - activity_=TAtype.ADD, - object_=VOtype.CASE_STATUS, - target_=VOtype.VULNERABILITY_CASE, -) -CreateParticipantStatus = ActivityPattern( - activity_=TAtype.CREATE, - object_=VOtype.PARTICIPANT_STATUS, -) -AddParticipantStatusToParticipant = ActivityPattern( - activity_=TAtype.ADD, - object_=VOtype.PARTICIPANT_STATUS, - target_=VOtype.CASE_PARTICIPANT, -) diff --git a/vultron/adapters/__init__.py b/vultron/adapters/__init__.py new file mode 100644 index 00000000..7ceb3f82 --- /dev/null +++ b/vultron/adapters/__init__.py @@ -0,0 +1,21 @@ +""" +Adapters layer for the Vultron hexagonal architecture. + +This package contains thin translation layers that connect the core domain +to external systems. No domain logic, no AS2 parsing, no semantic extraction +should live here — only translation and dispatch. + +Sub-packages: + +- ``driving/`` — Driving (left-side) adapters that trigger the core. + Examples: HTTP inbox, CLI, MCP server. + +- ``driven/`` — Driven (right-side) adapters that the core calls out to. + Examples: activity store, delivery queue, HTTP delivery. + +- ``connectors/`` — Bidirectional connector plugins (tracker integrations, + third-party systems). Each plugin translates between external events and + Vultron domain events. + +See ``notes/architecture-ports-and-adapters.md`` for the full design. +""" diff --git a/vultron/adapters/connectors/__init__.py b/vultron/adapters/connectors/__init__.py new file mode 100644 index 00000000..f9de73b3 --- /dev/null +++ b/vultron/adapters/connectors/__init__.py @@ -0,0 +1,18 @@ +""" +Connector adapters (bidirectional — tracker and third-party integrations). + +Connectors translate between external system events and Vultron domain +events. Unlike driving/driven adapters, connectors may both receive events +from and push events to external systems (e.g., issue trackers, mailing +lists, vulnerability databases). + +Modules: + +- ``base.py`` — ``ConnectorPlugin`` Protocol defining the plugin interface. +- ``loader.py`` — Entry-point–based plugin discovery and registration. + +Sub-packages: + +- ``example/`` — Reference implementations (Jira, VINCE) showing how to + implement a connector plugin. +""" diff --git a/vultron/adapters/connectors/base.py b/vultron/adapters/connectors/base.py new file mode 100644 index 00000000..b22db126 --- /dev/null +++ b/vultron/adapters/connectors/base.py @@ -0,0 +1,43 @@ +""" +ConnectorPlugin Protocol — base interface for bidirectional connector adapters. + +A connector translates between an external system's events and Vultron domain +events. Connectors differ from pure driving or driven adapters in that they +may both receive events from and push events to an external system (e.g., an +issue tracker, mailing list, or vulnerability database). +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ConnectorPlugin(Protocol): + """ + Interface that all Vultron connector plugins must satisfy. + + Plugins are discovered via Python entry points under the + ``vultron.connectors`` group (see ``loader.py``). + """ + + #: Human-readable name used in logs and configuration. + name: str + + def on_inbound_event(self, event: object) -> None: + """ + Called when the external system emits an event that should be + translated into a Vultron domain event and forwarded to the core. + + ``event`` is the raw external payload — the connector is responsible + for validating and translating it before calling a core use case. + """ + + def on_outbound_event(self, event: object) -> None: + """ + Called when a Vultron domain event should be forwarded to the + external system. + + ``event`` is a serialized domain event — the connector is responsible + for translating it into the external system's format and delivering it. + """ diff --git a/vultron/adapters/connectors/example/__init__.py b/vultron/adapters/connectors/example/__init__.py new file mode 100644 index 00000000..d1925a38 --- /dev/null +++ b/vultron/adapters/connectors/example/__init__.py @@ -0,0 +1,12 @@ +""" +Example connector plugins. + +These reference implementations illustrate how to build a ``ConnectorPlugin`` +for a third-party system. They are NOT production-ready — they exist only to +document the intended plugin pattern. + +Modules: + +- ``jira.py`` — Translates between Jira issue events and Vultron domain events. +- ``vince.py`` — Translates between VINCE platform events and Vultron domain events. +""" diff --git a/vultron/adapters/connectors/example/jira.py b/vultron/adapters/connectors/example/jira.py new file mode 100644 index 00000000..7f1fb593 --- /dev/null +++ b/vultron/adapters/connectors/example/jira.py @@ -0,0 +1,32 @@ +""" +Jira connector plugin — example stub. + +Illustrates how a ``ConnectorPlugin`` implementation translates between +Jira issue/comment events and Vultron domain events. + +Typical mappings (not yet implemented): + +- Jira issue created → Vultron ``create_report`` use case +- Jira comment added → Vultron ``add_note_to_case`` use case +- Vultron case closed → Jira issue resolved (via Jira REST API) + +See ``vultron/adapters/connectors/base.py`` for the ``ConnectorPlugin`` +Protocol this module must satisfy. +""" + +from vultron.adapters.connectors.base import ConnectorPlugin + + +class JiraConnector: + """Example Jira connector — not yet implemented.""" + + name = "jira" + + def on_inbound_event(self, event: object) -> None: + raise NotImplementedError + + def on_outbound_event(self, event: object) -> None: + raise NotImplementedError + + +assert isinstance(JiraConnector(), ConnectorPlugin) diff --git a/vultron/adapters/connectors/example/vince.py b/vultron/adapters/connectors/example/vince.py new file mode 100644 index 00000000..5eed5280 --- /dev/null +++ b/vultron/adapters/connectors/example/vince.py @@ -0,0 +1,33 @@ +""" +VINCE connector plugin — example stub. + +Illustrates how a ``ConnectorPlugin`` implementation translates between +VINCE (Vulnerability Information and Coordination Environment) platform +events and Vultron domain events. + +Typical mappings (not yet implemented): + +- VINCE case created → Vultron ``create_case`` use case +- VINCE vendor contacted → Vultron ``invite_actor_to_case`` use case +- VINCE disclosure published → Vultron ``announce_embargo_event_to_case`` use case + +See ``vultron/adapters/connectors/base.py`` for the ``ConnectorPlugin`` +Protocol this module must satisfy. +""" + +from vultron.adapters.connectors.base import ConnectorPlugin + + +class VinceConnector: + """Example VINCE connector — not yet implemented.""" + + name = "vince" + + def on_inbound_event(self, event: object) -> None: + raise NotImplementedError + + def on_outbound_event(self, event: object) -> None: + raise NotImplementedError + + +assert isinstance(VinceConnector(), ConnectorPlugin) diff --git a/vultron/adapters/connectors/loader.py b/vultron/adapters/connectors/loader.py new file mode 100644 index 00000000..e956e7eb --- /dev/null +++ b/vultron/adapters/connectors/loader.py @@ -0,0 +1,15 @@ +""" +Connector plugin loader — stub. + +Discovers and registers ``ConnectorPlugin`` implementations via Python +entry points under the ``vultron.connectors`` group. + +Future implementation will use ``importlib.metadata.entry_points`` to +enumerate installed plugins, validate them against the ``ConnectorPlugin`` +Protocol, and make them available to the adapter layer at startup. + +Example ``pyproject.toml`` entry for a third-party plugin:: + + [project.entry-points."vultron.connectors"] + my_tracker = "my_package.connectors:MyTrackerConnector" +""" diff --git a/vultron/adapters/driven/__init__.py b/vultron/adapters/driven/__init__.py new file mode 100644 index 00000000..632d75ee --- /dev/null +++ b/vultron/adapters/driven/__init__.py @@ -0,0 +1,17 @@ +""" +Driven adapters (right-side / secondary adapters). + +These adapters implement the port interfaces defined in ``core/ports/`` so +that the core can call out to external systems without being coupled to +them. The core defines the interface; this package provides the +implementation. + +Modules: + +- ``datalayer.py`` — Concrete activity persistence (e.g., TinyDB). +- ``delivery_queue.py`` — Outbound activity queue implementation. +- ``http_delivery.py`` — HTTP transport for outbound ActivityStreams + payloads (transport only — receives serialized + AS2 from the wire layer). +- ``dns_resolver.py`` — Optional DNS TXT lookup for trust discovery. +""" diff --git a/vultron/api/v2/datalayer/tinydb_backend.py b/vultron/adapters/driven/datalayer_tinydb.py similarity index 74% rename from vultron/api/v2/datalayer/tinydb_backend.py rename to vultron/adapters/driven/datalayer_tinydb.py index 51e15c5f..18d4ecf9 100644 --- a/vultron/api/v2/datalayer/tinydb_backend.py +++ b/vultron/adapters/driven/datalayer_tinydb.py @@ -9,30 +9,36 @@ # Created, in part, with funding and support from the United States Government # (see Acknowledgments file). This program may include and/or can make use of # certain third party source code, object code, documentation and other files -# (“Third Party Software”). See LICENSE.md for more details. +# ("Third Party Software"). See LICENSE.md for more details. # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -# Copyright - """ -Provides TODO writeme +TinyDB-backed activity store (driven adapter). + +Concrete implementation of the ``vultron.core.ports.activity_store.DataLayer`` +port for persisting and fetching ActivityStreams objects. + +The backward-compat re-export shim at +``vultron.api.v2.datalayer.tinydb_backend`` will be removed once all callers +are updated to import from this module directly. """ from typing import TypeVar from pydantic import BaseModel -from tinydb import TinyDB, Query +from tinydb import Query, TinyDB from tinydb.queries import QueryInstance from tinydb.storages import MemoryStorage from tinydb.table import Table -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.api.v2.datalayer.db_record import ( +from vultron.api.v2.data.utils import _URN_UUID_PREFIX, _UUID_RE +from vultron.adapters.driven.db_record import ( Record, object_to_record, record_to_object, ) +from vultron.core.ports.datalayer import DataLayer, StorableRecord BaseModelT = TypeVar("BaseModelT", bound=BaseModel) @@ -58,26 +64,32 @@ def _id_query(self, id_: str) -> QueryInstance: """ return Query()["id_"] == id_ - def create(self, record: Record | BaseModel) -> None: + def create(self, record: StorableRecord | BaseModel) -> None: """ Inserts a record into the specified table. - Accepts either a pre-built `Record` or a Pydantic `BaseModel` which will - be converted to a `Record` using `object_to_record`. + Accepts a ``StorableRecord`` (or its ``Record`` subclass) or any + other Pydantic ``BaseModel``. A plain ``StorableRecord`` is converted + to a full ``Record`` using its ``id_``/``type_``/``data_`` fields; a + non-``StorableRecord`` ``BaseModel`` is converted via + ``object_to_record``. Args: - record (Record | BaseModel): The record or model to insert. + record (StorableRecord | BaseModel): The record or model to insert. Raises: - ValueError: If a record with the same `_id` already exists. + ValueError: If a record with the same ``id_`` already exists. """ - # allow callers to pass either a Record wrapper or a BaseModel - if isinstance(record, Record): - rec = record + if isinstance(record, StorableRecord): + rec = Record( + id_=record.id_, type_=record.type_, data_=record.data_ + ) elif isinstance(record, BaseModel): rec = object_to_record(record) else: - raise ValueError("record must be a Record or Pydantic BaseModel") + raise ValueError( + "record must be a StorableRecord or a Pydantic BaseModel" + ) table = rec.type_ id_ = rec.id_ @@ -101,18 +113,27 @@ def read( Reads an object by id across all tables and returns the reconstituted Pydantic object (as_Base subclass) or None if not found. If `raise_on_missing` is True, raises a KeyError when the object is not found. + + Compatibility shim: when *object_id* is a bare UUID the lookup is + retried with the ``urn:uuid:`` prefix so that callers using the legacy + bare-UUID pattern still work while IDs are being migrated to URI form. """ - for name in self._db.tables(): - tbl = self._table(name) - rec = tbl.get(self._id_query(object_id)) - if rec: - # rec is a dict representing Record - try: - record = Record.model_validate(rec) - return record_to_object(record) - except Exception: - # fallback: if stored data is already the object dict, return it - return rec + candidates = [object_id] + if _UUID_RE.match(object_id): + candidates.append(f"{_URN_UUID_PREFIX}{object_id}") + + for candidate in candidates: + for name in self._db.tables(): + tbl = self._table(name) + rec = tbl.get(self._id_query(candidate)) + if rec: + # rec is a dict representing Record + try: + record = Record.model_validate(rec) + return record_to_object(record) + except Exception: + # fallback: if stored data is already the object dict, return it + return rec if raise_on_missing: raise KeyError( f"Object with id '{object_id}' not found in datalayer" @@ -160,21 +181,40 @@ def get_all(self, table: str) -> list[dict]: records = tbl.all() return records - def update(self, id_: str, record: Record) -> bool: + def update(self, id_: str, record: StorableRecord) -> bool: """ Updates a record by id in the specified table. + Accepts a ``StorableRecord`` (or its ``Record`` subclass) with + ``id_``, ``type_``, and ``data_`` fields. + Args: - table (str): The name of the table. id_ (str): The id of the record to update. - record (dict): The new record data. + record (StorableRecord): The new record data. Returns: bool: True if a record was updated, False if not found. """ - tbl = self._table(record.type_) - updated = tbl.update(record.model_dump(), self._id_query(id_)) + rec = Record(id_=record.id_, type_=record.type_, data_=record.data_) + tbl = self._table(rec.type_) + updated = tbl.update(rec.model_dump(), self._id_query(id_)) return len(updated) > 0 + def save(self, obj: BaseModel) -> None: + """Persist a domain object to the DataLayer, overwriting any existing record. + + Unlike ``create()``, ``save()`` does not raise if the object already exists. + Use this for update operations where the caller owns the ID. + + Args: + obj: Any Pydantic BaseModel with ``as_id`` and ``as_type`` fields. + """ + rec = object_to_record(obj) + tbl = self._table(rec.type_) + if tbl.contains(self._id_query(rec.id_)): + tbl.update(rec.model_dump(), self._id_query(rec.id_)) + else: + tbl.insert(rec.model_dump()) + def delete(self, table: str, id_: str) -> bool: """ Deletes a record by id from the specified table. @@ -340,11 +380,3 @@ def reset_datalayer() -> None: """Reset the singleton datalayer instance. Used primarily for testing.""" global _datalayer_instance _datalayer_instance = None - - -def main(): - pass - - -if __name__ == "__main__": - main() diff --git a/vultron/api/v2/datalayer/db_record.py b/vultron/adapters/driven/db_record.py similarity index 86% rename from vultron/api/v2/datalayer/db_record.py rename to vultron/adapters/driven/db_record.py index c8cff752..eca9f5c3 100644 --- a/vultron/api/v2/datalayer/db_record.py +++ b/vultron/adapters/driven/db_record.py @@ -21,21 +21,19 @@ from pydantic import BaseModel -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.registry import Vocabulary, find_in_vocabulary +from vultron.core.ports.datalayer import StorableRecord +from vultron.wire.as2.vocab.base.registry import find_in_vocabulary -class Record(BaseModel): +class Record(StorableRecord): """Record wrapper stored in TinyDB. - Internally fields are `id_`, `type_`, and `data_`. - `type_` is intended to hold the class name of the stored object, and will used to select - both the table name and the class to reconstitute the object when reading. - `data_` holds the actual data of the object as a dict. - """ - id_: str - type_: str - data_: dict + Extends ``StorableRecord`` (from ``core/ports/``) with adapter-layer + helpers for converting to/from domain objects via the wire vocabulary. + Internally fields are ``id_``, ``type_``, and ``data_``. + ``type_`` selects both the table name and the class used to reconstitute + the object when reading. ``data_`` holds the object's serialised data. + """ @classmethod def from_obj(cls, obj: BaseModel) -> "Record": diff --git a/vultron/adapters/driven/delivery_queue.py b/vultron/adapters/driven/delivery_queue.py new file mode 100644 index 00000000..dcfc6aa0 --- /dev/null +++ b/vultron/adapters/driven/delivery_queue.py @@ -0,0 +1,15 @@ +""" +Delivery queue driven adapter — stub. + +Concrete implementation of the ``core/ports/delivery_queue.py`` port +interface for enqueuing outbound ActivityStreams activities. + +Future implementation will back the queue with an in-process asyncio +queue (development) or a durable message broker (production). + +The current placeholder outbox stub lives in +``vultron/api/v2/backend/actor_io.py``. This module is reserved for the +real implementation as part of the OUTBOX-1 work. + +See ``plan/IMPLEMENTATION_PLAN.md`` Phase OUTBOX-1 for task details. +""" diff --git a/vultron/adapters/driven/dns_resolver.py b/vultron/adapters/driven/dns_resolver.py new file mode 100644 index 00000000..cbe7494c --- /dev/null +++ b/vultron/adapters/driven/dns_resolver.py @@ -0,0 +1,14 @@ +""" +DNS resolver driven adapter — stub. + +Concrete implementation of the ``core/ports/dns_resolver.py`` port +interface for DNS TXT-based actor/instance trust discovery. + +Future implementation will perform DNS TXT lookups to resolve Vultron +instance metadata (public keys, inbox URLs, supported protocol versions) +following the WebFinger / NodeInfo conventions used by ActivityPub +implementations. + +This adapter is optional and will only be wired in when DNS-based trust +discovery is required. +""" diff --git a/vultron/adapters/driven/http_delivery.py b/vultron/adapters/driven/http_delivery.py new file mode 100644 index 00000000..7ade01ba --- /dev/null +++ b/vultron/adapters/driven/http_delivery.py @@ -0,0 +1,18 @@ +""" +HTTP delivery driven adapter — stub. + +Handles transport of outbound ActivityStreams payloads to remote actor +inboxes via HTTP POST. + +Responsibilities (future implementation): + +- Receive a serialized AS2 JSON payload and a target inbox URL. +- Sign the request with the local actor's HTTP Signature private key. +- POST the payload to the target inbox. +- Retry on transient failures with exponential back-off. +- Record delivery success/failure in the activity store. + +This adapter is transport-only — it must not construct or inspect AS2 +objects. Serialization is handled by ``wire/as2/serializer.py`` before +the payload reaches this adapter. +""" diff --git a/vultron/adapters/driving/__init__.py b/vultron/adapters/driving/__init__.py new file mode 100644 index 00000000..3b05a3d8 --- /dev/null +++ b/vultron/adapters/driving/__init__.py @@ -0,0 +1,14 @@ +""" +Driving adapters (left-side / primary adapters). + +These adapters receive external requests and translate them into core +use-case calls. They trigger the application — the core does not know +about them. + +Modules: + +- ``cli.py`` — Command-line interface adapter. +- ``http_inbox.py`` — FastAPI endpoint → wire/as2 pipeline → core. +- ``mcp_server.py`` — MCP server adapter for AI agent tool calls. +- ``shared_inbox.py`` — Shared-inbox endpoint for federated delivery. +""" diff --git a/vultron/adapters/driving/cli.py b/vultron/adapters/driving/cli.py new file mode 100644 index 00000000..7d2fd83a --- /dev/null +++ b/vultron/adapters/driving/cli.py @@ -0,0 +1,9 @@ +""" +CLI driving adapter — stub. + +Translates command-line invocations into core use-case calls. +Implements the CLI port of the hexagonal architecture. + +Future implementation will use ``click`` (consistent with ``vultron/demo/cli.py``) +and delegate to the same use-case callables used by the HTTP inbox adapter. +""" diff --git a/vultron/adapters/driving/http_inbox.py b/vultron/adapters/driving/http_inbox.py new file mode 100644 index 00000000..e12d8fcd --- /dev/null +++ b/vultron/adapters/driving/http_inbox.py @@ -0,0 +1,18 @@ +""" +HTTP inbox driving adapter — stub. + +Translates FastAPI inbox POST requests into core use-case calls by routing +through the wire/as2 parsing and semantic extraction pipeline. + +Pipeline (future implementation): + +1. Deserialize raw JSON → AS2 types (``wire/as2/parser.py``) +2. Rehydrate URI references (``api/v2/data/rehydration.py``) +3. Extract MessageSemantics (``wire/as2/extractor.py``) +4. Dispatch to use-case callable (``core/use_cases/``) +5. Return HTTP 202 Accepted + +The current HTTP inbox lives in ``vultron/api/v2/routers/actors.py``. +This module is reserved for the future relocation of that logic into the +adapter layer as part of the hexagonal architecture refactor (PRIORITY 60+). +""" diff --git a/vultron/adapters/driving/mcp_server.py b/vultron/adapters/driving/mcp_server.py new file mode 100644 index 00000000..231a873c --- /dev/null +++ b/vultron/adapters/driving/mcp_server.py @@ -0,0 +1,13 @@ +""" +MCP server driving adapter — stub. + +Exposes Vultron use-case callables as MCP (Model Context Protocol) tools so +that AI agents can invoke them directly without going through the HTTP API. + +Future implementation will register one MCP tool per use-case callable +defined in ``core/use_cases/`` and handle authentication / authorization +at the adapter boundary. + +See ``plan/PRIORITIES.md`` PRIORITY 1000 (Agentic AI readiness) for the +design rationale. +""" diff --git a/vultron/adapters/driving/shared_inbox.py b/vultron/adapters/driving/shared_inbox.py new file mode 100644 index 00000000..371486d8 --- /dev/null +++ b/vultron/adapters/driving/shared_inbox.py @@ -0,0 +1,16 @@ +""" +Shared inbox driving adapter — stub. + +Handles ActivityPub shared-inbox delivery — a single endpoint that accepts +activities addressed to multiple local actors and fans them out to per-actor +inboxes. + +Future implementation will: + +1. Validate the HTTP Signature on the inbound request. +2. Identify the addressed local actors from the activity ``to``/``cc`` fields. +3. Enqueue one inbox delivery per addressed actor via the delivery queue port. + +The shared inbox is a driving adapter because it triggers the core (fan-out +use case), but it also interacts with the driven delivery queue adapter. +""" diff --git a/vultron/api/main.py b/vultron/api/main.py index 581b02c3..2e453f3e 100644 --- a/vultron/api/main.py +++ b/vultron/api/main.py @@ -16,6 +16,8 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.responses import HTMLResponse from fastapi.responses import RedirectResponse @@ -26,10 +28,29 @@ # # from api.v2.app import app_v2 + +@asynccontextmanager +async def lifespan(application: FastAPI): + """Root app lifespan — runs startup tasks not covered by sub-app lifespans. + + Starlette does not automatically propagate lifespan events to mounted + sub-applications, so any initialisation that must run before the first + request (e.g. the inbox dispatcher) is performed here as well as in the + ``app_v2`` lifespan (which fires when that sub-app is used directly, + e.g. in unit tests targeting ``app_v2`` directly). + """ + from vultron.api.v2.backend.inbox_handler import init_dispatcher + from vultron.adapters.driven.datalayer_tinydb import get_datalayer + + init_dispatcher(dl=get_datalayer()) + yield + + app = FastAPI( title="Vultron API Home", docs_url=None, # disable default docs redoc_url=None, + lifespan=lifespan, ) # Mount each version diff --git a/vultron/api/v1/routers/actors.py b/vultron/api/v1/routers/actors.py index 3324b44c..97f59f7c 100644 --- a/vultron/api/v1/routers/actors.py +++ b/vultron/api/v1/routers/actors.py @@ -18,13 +18,13 @@ from fastapi import APIRouter -from vultron.as_vocab.activities.case import ( - OfferCaseOwnershipTransfer, - RmInviteToCase, +from vultron.wire.as2.vocab.activities.case import ( + OfferCaseOwnershipTransferActivity, + RmInviteToCaseActivity, ) -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.examples import vocab_examples +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.examples import vocab_examples router = APIRouter(prefix="/actors", tags=["Actors"]) @@ -45,7 +45,7 @@ def get_actors() -> as_Actor: @router.post( "/{actor_id}/cases/offers", - response_model=OfferCaseOwnershipTransfer, + response_model=OfferCaseOwnershipTransferActivity, response_model_exclude_none=True, summary="Offer Case to Actor", description="Offers a Vulnerability Case to an Actor.", @@ -53,19 +53,21 @@ def get_actors() -> as_Actor: ) def offer_case_to_actor( id: str, case: VulnerabilityCase -) -> OfferCaseOwnershipTransfer: +) -> OfferCaseOwnershipTransferActivity: """Offers a Vulnerability Case to an Actor.""" return vocab_examples.offer_case_ownership_transfer() @router.post( "/{actor_id}/cases/invitations", - response_model=RmInviteToCase, + response_model=RmInviteToCaseActivity, response_model_exclude_none=True, summary="Invite Actor to Case", description="Invites an Actor to a Vulnerability Case.", tags=["Invite Actor to Case", "Cases"], ) -def invite_actor_to_case(id: str, case: VulnerabilityCase) -> RmInviteToCase: +def invite_actor_to_case( + id: str, case: VulnerabilityCase +) -> RmInviteToCaseActivity: """Invites an Actor to a Vulnerability Case.""" return vocab_examples.invite_to_case() diff --git a/vultron/api/v1/routers/cases.py b/vultron/api/v1/routers/cases.py index ddbeedcb..b67b10e2 100644 --- a/vultron/api/v1/routers/cases.py +++ b/vultron/api/v1/routers/cases.py @@ -18,34 +18,36 @@ from fastapi import APIRouter -from vultron.as_vocab.activities.actor import ( - RecommendActor, - AcceptActorRecommendation, - RejectActorRecommendation, +from vultron.wire.as2.vocab.activities.actor import ( + RecommendActorActivity, + AcceptActorRecommendationActivity, + RejectActorRecommendationActivity, ) -from vultron.as_vocab.activities.case import ( - CreateCase, - AddReportToCase, - RmEngageCase, - RmCloseCase, - RmDeferCase, - AddNoteToCase, - UpdateCase, - RmAcceptInviteToCase, - RmRejectInviteToCase, - CreateCaseStatus, - AcceptCaseOwnershipTransfer, - RejectCaseOwnershipTransfer, - AddStatusToCase, +from vultron.wire.as2.vocab.activities.case import ( + CreateCaseActivity, + AddReportToCaseActivity, + RmEngageCaseActivity, + RmCloseCaseActivity, + RmDeferCaseActivity, + AddNoteToCaseActivity, + UpdateCaseActivity, + RmAcceptInviteToCaseActivity, + RmRejectInviteToCaseActivity, + CreateCaseStatusActivity, + AcceptCaseOwnershipTransferActivity, + RejectCaseOwnershipTransferActivity, + AddStatusToCaseActivity, ) -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Undo, as_Create, ) -from vultron.as_vocab.objects.case_status import CaseStatus -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.as_vocab.examples import vocab_examples +from vultron.wire.as2.vocab.objects.case_status import CaseStatus +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.wire.as2.vocab.examples import vocab_examples router = APIRouter(prefix="/cases", tags=["Cases"]) case_router = APIRouter(prefix="/cases/{case_id}", tags=["Cases"]) @@ -68,12 +70,12 @@ async def get_cases() -> list[VulnerabilityCase]: @router.post( "/", - response_model=CreateCase, + response_model=CreateCaseActivity, response_model_exclude_none=True, description="Create a new Vulnerability Case object. (This is a stub implementation.)", tags=["Cases"], ) -async def create_case(case: VulnerabilityCase) -> CreateCase: +async def create_case(case: VulnerabilityCase) -> CreateCaseActivity: """Creates a VulnerabilityCase object.""" return vocab_examples.create_case() @@ -83,12 +85,14 @@ async def create_case(case: VulnerabilityCase) -> CreateCase: @case_router.put( "/", - response_model=UpdateCase, + response_model=UpdateCaseActivity, response_model_exclude_none=True, description="Update a Vulnerability Case. (This is a stub implementation.)", tags=["Cases"], ) -async def update_case(case_id: str, case: VulnerabilityCase) -> UpdateCase: +async def update_case( + case_id: str, case: VulnerabilityCase +) -> UpdateCaseActivity: """Update a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.update_case() @@ -96,26 +100,26 @@ async def update_case(case_id: str, case: VulnerabilityCase) -> UpdateCase: # TODO move to reports router? @case_router.post( "/reports", - response_model=AddReportToCase, + response_model=AddReportToCaseActivity, response_model_exclude_none=True, description="Add a new report to an existing Vulnerability Case. (This is a stub implementation.)", tags=["Cases", "Reports"], ) async def post_report_to_case( case_id: str, report: VulnerabilityReport -) -> AddReportToCase: +) -> AddReportToCaseActivity: """Adds a new report to an existing VulnerabilityCase object.""" return vocab_examples.add_report_to_case() @case_router.post( "/engage", - response_model=RmEngageCase, + response_model=RmEngageCaseActivity, response_model_exclude_none=True, description="Engage a Vulnerability Case by ID. (This is a stub implementation.)", tags=["Cases"], ) -async def engage_case_by_id(case_id: str) -> RmEngageCase: +async def engage_case_by_id(case_id: str) -> RmEngageCaseActivity: """Engage a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve and engage the case from a database. return vocab_examples.engage_case() @@ -123,12 +127,12 @@ async def engage_case_by_id(case_id: str) -> RmEngageCase: @case_router.post( "/close", - response_model=RmCloseCase, + response_model=RmCloseCaseActivity, response_model_exclude_none=True, description="Close a Vulnerability Case by ID. (This is a stub implementation.)", tags=["Cases"], ) -async def close_case_by_id(case_id: str) -> RmCloseCase: +async def close_case_by_id(case_id: str) -> RmCloseCaseActivity: """Close a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve and close the case from a database. return vocab_examples.close_case() @@ -136,12 +140,12 @@ async def close_case_by_id(case_id: str) -> RmCloseCase: @case_router.post( "/defer", - response_model=RmDeferCase, + response_model=RmDeferCaseActivity, response_model_exclude_none=True, description="Defer a Vulnerability Case by ID. (This is a stub implementation.)", tags=["Cases"], ) -async def defer_case_by_id(case_id: str) -> RmDeferCase: +async def defer_case_by_id(case_id: str) -> RmDeferCaseActivity: """Defer a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve and defer the case from a database. return vocab_examples.defer_case() @@ -162,12 +166,14 @@ async def reengage_case_by_id(case_id: str) -> as_Undo: @case_router.post( "/reports/{report_id}", - response_model=AddReportToCase, + response_model=AddReportToCaseActivity, response_model_exclude_none=True, description="Associate an existing report to an existing Vulnerability Case. (This is a stub implementation.)", tags=["Cases", "Reports"], ) -async def add_report_to_case(case_id: str, report_id: str) -> AddReportToCase: +async def add_report_to_case( + case_id: str, report_id: str +) -> AddReportToCaseActivity: """Adds a report to an existing VulnerabilityCase object.""" return vocab_examples.add_report_to_case() @@ -186,14 +192,14 @@ async def add_note_to_case(case_id: str): @router.post( "/{case_id}/notes/{note_id}", - response_model=AddNoteToCase, + response_model=AddNoteToCaseActivity, response_model_exclude_none=True, description="Associate an existing note to a case. (This is a stub implementation.)", tags=["Cases", "Notes"], ) async def add_existing_note_to_case( case_id: str, note_id: str -) -> AddNoteToCase: +) -> AddNoteToCaseActivity: """Stub for associating an existing note to a case.""" return vocab_examples.add_note_to_case() @@ -203,42 +209,42 @@ async def add_existing_note_to_case( @router.post( "/{case_id}/recommendations", - response_model=RecommendActor, + response_model=RecommendActorActivity, response_model_exclude_none=True, description="Recommend an Actor for a Vulnerability Case. (This is a stub implementation.)", tags=["Cases", "Actors", "Recommend Actors"], ) async def recommend_actor_for_case( case_id: str, actor_id: str -) -> RecommendActor: +) -> RecommendActorActivity: """Recommend an Actor for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.recommend_actor() @router.post( "/{case_id}/recommendations/{recommendation_id}/accept", - response_model=AcceptActorRecommendation, + response_model=AcceptActorRecommendationActivity, response_model_exclude_none=True, description="Accept an Actor recommendation for a Vulnerability Case. (This is a stub implementation.)", tags=["Recommend Actors"], ) async def accept_actor_recommendation_for_case( case_id: str, recommendation_id: str -) -> AcceptActorRecommendation: +) -> AcceptActorRecommendationActivity: """Accept an Actor recommendation for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.accept_actor_recommendation() @router.post( "/{case_id}/recommendations/{recommendation_id}/reject", - response_model=RejectActorRecommendation, + response_model=RejectActorRecommendationActivity, response_model_exclude_none=True, description="Reject an Actor recommendation for a Vulnerability Case. (This is a stub implementation.)", tags=["Recommend Actors"], ) async def reject_actor_recommendation_for_case( case_id: str, recommendation_id: str -) -> RejectActorRecommendation: +) -> RejectActorRecommendationActivity: """Reject an Actor recommendation for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.reject_actor_recommendation() @@ -246,7 +252,7 @@ async def reject_actor_recommendation_for_case( # Offer methods @router.post( "/{case_id}/offers/{offer_id}/accept", - response_model=AcceptCaseOwnershipTransfer, + response_model=AcceptCaseOwnershipTransferActivity, response_model_exclude_none=True, summary="Accept Case Offer", description="Accepts a case offer by an actor.", @@ -254,7 +260,9 @@ async def reject_actor_recommendation_for_case( "Case Ownership Transfers", ], ) -def accept_case_offer(id: str, offer_id: str) -> AcceptCaseOwnershipTransfer: +def accept_case_offer( + id: str, offer_id: str +) -> AcceptCaseOwnershipTransferActivity: """Accepts a case offer by an actor.""" return vocab_examples.accept_case_ownership_transfer() @@ -262,7 +270,7 @@ def accept_case_offer(id: str, offer_id: str) -> AcceptCaseOwnershipTransfer: # reject a case offer by an actor @router.post( "/{case_id}/offers/{offer_id}/reject", - response_model=RejectCaseOwnershipTransfer, + response_model=RejectCaseOwnershipTransferActivity, response_model_exclude_none=True, summary="Reject Case Offer", description="Rejects a case offer by an actor.", @@ -270,7 +278,9 @@ def accept_case_offer(id: str, offer_id: str) -> AcceptCaseOwnershipTransfer: "Case Ownership Transfers", ], ) -def reject_case_offer(id: str, offer_id: str) -> RejectCaseOwnershipTransfer: +def reject_case_offer( + id: str, offer_id: str +) -> RejectCaseOwnershipTransferActivity: """Rejects a case offer by an actor.""" return vocab_examples.reject_case_ownership_transfer() @@ -280,7 +290,7 @@ def reject_case_offer(id: str, offer_id: str) -> RejectCaseOwnershipTransfer: @router.post( "/{case_id}/invitations/{invitation_id}/accept", - response_model=RmAcceptInviteToCase, + response_model=RmAcceptInviteToCaseActivity, response_model_exclude_none=True, description="Accept an invitation to a Vulnerability Case. (This is a stub implementation.)", summary="Accept Invitation to Case", @@ -288,14 +298,14 @@ def reject_case_offer(id: str, offer_id: str) -> RejectCaseOwnershipTransfer: ) async def accept_invitation_to_case( case_id: str, invitation_id: str -) -> RmAcceptInviteToCase: +) -> RmAcceptInviteToCaseActivity: """Accept an invitation to a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.accept_invite_to_case() @router.post( "/{case_id}/invitations/{invitation_id}/reject", - response_model=RmRejectInviteToCase, + response_model=RmRejectInviteToCaseActivity, response_model_exclude_none=True, description="Reject an invitation to a Vulnerability Case. (This is a stub implementation.)", summary="Reject Invitation to Case", @@ -303,7 +313,7 @@ async def accept_invitation_to_case( ) async def reject_invitation_to_case( case_id: str, invitation_id: str -) -> RmRejectInviteToCase: +) -> RmRejectInviteToCaseActivity: """Reject an invitation to a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.reject_invite_to_case() @@ -327,25 +337,27 @@ async def get_case_statuses(case_id: str) -> list[CaseStatus]: @router.post( "/{case_id}/statuses", - response_model=CreateCaseStatus, + response_model=CreateCaseStatusActivity, response_model_exclude_none=True, description="Create a new Case Status for a Vulnerability Case. (This is a stub implementation.)", tags=["Statuses"], ) async def create_case_status( case_id: str, status: CaseStatus -) -> CreateCaseStatus: +) -> CreateCaseStatusActivity: """Creates a new CaseStatus for a VulnerabilityCase.""" return vocab_examples.create_case_status() @router.post( "/{case_id}/statuses/{status_id}", - response_model=AddStatusToCase, + response_model=AddStatusToCaseActivity, response_model_exclude_none=True, description="Add an existing status to a Vulnerability Case. (This is a stub implementation)", tags=["Statuses"], ) -async def add_status_to_case(case_id: str, status_id: str) -> AddStatusToCase: +async def add_status_to_case( + case_id: str, status_id: str +) -> AddStatusToCaseActivity: """Adds an existing status to a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.add_status_to_case() diff --git a/vultron/api/v1/routers/embargoes.py b/vultron/api/v1/routers/embargoes.py index 166ab2b8..0cb70082 100644 --- a/vultron/api/v1/routers/embargoes.py +++ b/vultron/api/v1/routers/embargoes.py @@ -17,24 +17,24 @@ from fastapi import APIRouter -from vultron.as_vocab.activities.embargo import ( - EmProposeEmbargo, - RemoveEmbargoFromCase, - AnnounceEmbargo, - ActivateEmbargo, - AddEmbargoToCase, - EmRejectEmbargo, - EmAcceptEmbargo, +from vultron.wire.as2.vocab.activities.embargo import ( + EmProposeEmbargoActivity, + RemoveEmbargoFromCaseActivity, + AnnounceEmbargoActivity, + ActivateEmbargoActivity, + AddEmbargoToCaseActivity, + EmRejectEmbargoActivity, + EmAcceptEmbargoActivity, ) -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.as_vocab.examples import vocab_examples +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.examples import vocab_examples router = APIRouter(prefix="/cases/{case_id}/embargoes", tags=["Embargoes"]) @router.post( "/", - response_model=AddEmbargoToCase, + response_model=AddEmbargoToCaseActivity, response_model_exclude_none=True, summary="Add Embargo to Case (Case Owners Only)", description="Add an embargo to a Vulnerability Case. (This is a stub implementation)", @@ -42,14 +42,14 @@ ) async def add_embargo_to_case( case_id: str, embargo: EmbargoEvent -) -> AddEmbargoToCase: +) -> AddEmbargoToCaseActivity: """Add an embargo to a VulnerabilityCase. This endpoint is available to case owners only. (This is a stub implementation.)""" return vocab_examples.add_embargo_to_case() @router.post( "/propose", - response_model=EmProposeEmbargo, + response_model=EmProposeEmbargoActivity, response_model_exclude_none=True, summary="Propose Embargo for Case (Any Case Participant)", description="Propose an embargo for a Vulnerability Case. (This is a stub implementation)", @@ -57,7 +57,7 @@ async def add_embargo_to_case( ) async def propose_embargo_for_case( case_id: str, embargo: EmbargoEvent -) -> EmProposeEmbargo: +) -> EmProposeEmbargoActivity: """Propose an embargo for a VulnerabilityCase. This endpoint is available to any case participant. (This is a stub implementation.)""" return vocab_examples.propose_embargo() @@ -68,14 +68,14 @@ async def propose_embargo_for_case( # accept embargo @router.post( "/{embargo_id}/accept", - response_model=EmAcceptEmbargo, + response_model=EmAcceptEmbargoActivity, response_model_exclude_none=True, description="Accept an embargo for a Vulnerability Case. (This is a stub implementation)", tags=["Embargoes"], ) async def accept_embargo_for_case( case_id: str, embargo_id: str -) -> EmAcceptEmbargo: +) -> EmAcceptEmbargoActivity: """Accept an embargo for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.accept_embargo() @@ -83,14 +83,14 @@ async def accept_embargo_for_case( # reject embargo @router.post( "/{embargo_id}/reject", - response_model=EmRejectEmbargo, + response_model=EmRejectEmbargoActivity, response_model_exclude_none=True, description="Reject an embargo for a Vulnerability Case. (This is a stub implementation)", tags=["Embargoes"], ) async def reject_embargo_for_case( case_id: str, embargo_id: str -) -> EmRejectEmbargo: +) -> EmRejectEmbargoActivity: """Reject an embargo for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.reject_embargo() @@ -98,14 +98,14 @@ async def reject_embargo_for_case( # activate embargo @router.post( "/{embargo_id}/activate", - response_model=ActivateEmbargo, + response_model=ActivateEmbargoActivity, response_model_exclude_none=True, description="Activate an embargo for a Vulnerability Case. (This is a stub implementation)", tags=["Embargoes"], ) async def activate_embargo_for_case( case_id: str, embargo_id: str -) -> ActivateEmbargo: +) -> ActivateEmbargoActivity: """Activate an embargo for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.activate_embargo() @@ -113,14 +113,14 @@ async def activate_embargo_for_case( # announce embargo @router.post( "/announce", - response_model=AnnounceEmbargo, + response_model=AnnounceEmbargoActivity, response_model_exclude_none=True, description="Announce the active embargo for a Vulnerability Case. (This is a stub implementation)", tags=["Embargoes"], ) async def announce_embargo_for_case( case_id: str, embargo_id: str -) -> AnnounceEmbargo: +) -> AnnounceEmbargoActivity: """Announce an embargo for a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.announce_embargo() @@ -128,7 +128,7 @@ async def announce_embargo_for_case( # remove embargo from case @router.delete( "/{embargo_id}", - response_model=RemoveEmbargoFromCase, + response_model=RemoveEmbargoFromCaseActivity, response_model_exclude_none=True, summary="Remove Embargo from Case (Case Owners Only)", description="Remove an embargo from a Vulnerability Case. (This is a stub implementation)", @@ -136,6 +136,6 @@ async def announce_embargo_for_case( ) async def remove_embargo_from_case( case_id: str, embargo_id: str -) -> RemoveEmbargoFromCase: +) -> RemoveEmbargoFromCaseActivity: """Remove an embargo from a VulnerabilityCase. This endpoint is available to case owners only. (This is a stub implementation.)""" return vocab_examples.remove_embargo() diff --git a/vultron/api/v1/routers/examples.py b/vultron/api/v1/routers/examples.py index 1e0ef6d0..421912c3 100644 --- a/vultron/api/v1/routers/examples.py +++ b/vultron/api/v1/routers/examples.py @@ -19,14 +19,19 @@ from fastapi import APIRouter -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.object_types import as_Note -from vultron.as_vocab.objects.case_participant import CaseParticipant -from vultron.as_vocab.objects.case_status import CaseStatus, ParticipantStatus -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.as_vocab.examples import vocab_examples +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.object_types import as_Note +from vultron.wire.as2.vocab.objects.case_participant import CaseParticipant +from vultron.wire.as2.vocab.objects.case_status import ( + CaseStatus, + ParticipantStatus, +) +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.wire.as2.vocab.examples import vocab_examples router = APIRouter( prefix="/examples", diff --git a/vultron/api/v1/routers/participants.py b/vultron/api/v1/routers/participants.py index b2d5ce94..11e0b98c 100644 --- a/vultron/api/v1/routers/participants.py +++ b/vultron/api/v1/routers/participants.py @@ -19,17 +19,17 @@ from fastapi import APIRouter -from vultron.as_vocab.activities.case_participant import ( - RemoveParticipantFromCase, - AddStatusToParticipant, - CreateParticipant, - AddParticipantToCase, +from vultron.wire.as2.vocab.activities.case_participant import ( + RemoveParticipantFromCaseActivity, + AddStatusToParticipantActivity, + CreateParticipantActivity, + AddParticipantToCaseActivity, ) -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_status import ParticipantStatus +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus from vultron.bt.roles.states import CVDRoles -from vultron.as_vocab.examples import vocab_examples -from vultron.as_vocab.examples.participant import ( +from vultron.wire.as2.vocab.examples import vocab_examples +from vultron.wire.as2.vocab.examples.participant import ( add_vendor_participant_to_case, add_finder_participant_to_case, add_coordinator_participant_to_case, @@ -43,28 +43,28 @@ @cp_router.post( "/", - response_model=CreateParticipant, + response_model=CreateParticipantActivity, response_model_exclude_none=True, description="Add a new participant to an existing Vulnerability Case. (This is a stub implementation.)", tags=["Cases", "Participants"], ) async def add_actor_to_case_as_participant( case_id: str, actor: as_Actor, case_roles: list[CVDRoles] -) -> CreateParticipant: +) -> CreateParticipantActivity: """Adds a participant to an existing VulnerabilityCase object.""" return vocab_examples.create_participant() @cp_router.post( "/{participant_id}", - response_model=AddParticipantToCase, + response_model=AddParticipantToCaseActivity, response_model_exclude_none=True, description="Associate an actor to an existing Vulnerability Case as a participant. (This is a stub implementation.)", tags=["Cases", "Participants", "Actors"], ) async def add_existing_participant_to_case( case_id: str, participant_id: str -) -> AddParticipantToCase: +) -> AddParticipantToCaseActivity: """Adds a participant to an existing VulnerabilityCase object.""" options = [ add_vendor_participant_to_case, @@ -77,14 +77,14 @@ async def add_existing_participant_to_case( @cp_router.delete( "/{participant_id}", - response_model=RemoveParticipantFromCase, + response_model=RemoveParticipantFromCaseActivity, response_model_exclude_none=True, description="Remove a participant from a Vulnerability Case. (This is a stub implementation.)", tags=["Cases", "Participants"], ) async def remove_participant_from_case( case_id: str, participant_id: str -) -> RemoveParticipantFromCase: +) -> RemoveParticipantFromCaseActivity: """Removes a participant from a VulnerabilityCase. (This is a stub implementation.)""" return vocab_examples.remove_participant_from_case() @@ -107,13 +107,13 @@ async def get_participant_statuses( @cp_router.post( path="/{participant_id}/statuses", - response_model=AddStatusToParticipant, + response_model=AddStatusToParticipantActivity, response_model_exclude_none=True, description="Add a new status to a participant in a Vulnerability Case. (This is a stub implementation.)", tags=["Statuses", "Participants"], ) async def add_status_to_participant( case_id: str, participant_id: str, status: ParticipantStatus -) -> AddStatusToParticipant: +) -> AddStatusToParticipantActivity: """Adds a new status to a participant in a VulnerabilityCase.""" return vocab_examples.add_status_to_participant() diff --git a/vultron/api/v1/routers/reports.py b/vultron/api/v1/routers/reports.py index 9bd2bcbf..7dc6886d 100644 --- a/vultron/api/v1/routers/reports.py +++ b/vultron/api/v1/routers/reports.py @@ -17,16 +17,18 @@ # U.S. Patent and Trademark Office by Carnegie Mellon University from fastapi import APIRouter -from vultron.as_vocab.activities.report import ( - RmCloseReport, - RmInvalidateReport, - RmValidateReport, - RmReadReport, - RmSubmitReport, - RmCreateReport, +from vultron.wire.as2.vocab.activities.report import ( + RmCloseReportActivity, + RmInvalidateReportActivity, + RmValidateReportActivity, + RmReadReportActivity, + RmSubmitReportActivity, + RmCreateReportActivity, ) -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.as_vocab.examples import vocab_examples +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.wire.as2.vocab.examples import vocab_examples router = APIRouter(prefix="/reports", tags=["Reports"]) @@ -46,7 +48,7 @@ def get_reports() -> list[VulnerabilityReport]: "/", description="Create a new Vulnerability Report object. (This is a stub implementation.)", ) -def create_report(report: VulnerabilityReport) -> RmCreateReport: +def create_report(report: VulnerabilityReport) -> RmCreateReportActivity: """Creates a VulnerabilityReport object.""" return vocab_examples.create_report() @@ -55,11 +57,11 @@ def create_report(report: VulnerabilityReport) -> RmCreateReport: # Answer: No, because this represents an Offer(Report) activity vs a Create(Report) activity @router.post( "/submit", - response_model=RmSubmitReport, + response_model=RmSubmitReportActivity, response_model_exclude_none=True, description="Submit a Vulnerability Report. (This is a stub implementation.)", ) -async def submit_report(report: VulnerabilityReport) -> RmSubmitReport: +async def submit_report(report: VulnerabilityReport) -> RmSubmitReportActivity: """Submit a new VulnerabilityCase object.""" # In a real implementation, you would save the case to a database or perform other actions. return vocab_examples.submit_report() @@ -67,11 +69,11 @@ async def submit_report(report: VulnerabilityReport) -> RmSubmitReport: @router.put( "/{report_id}/read", - response_model=RmReadReport, + response_model=RmReadReportActivity, response_model_exclude_none=True, description="Acknowledge a report has been read. (This is a stub implementation.)", ) -async def read_case(id: str) -> RmReadReport: +async def read_case(id: str) -> RmReadReportActivity: """Read a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve the case from a database. return vocab_examples.read_report() @@ -79,11 +81,11 @@ async def read_case(id: str) -> RmReadReport: @router.put( "/{report_id}/valid", - response_model=RmValidateReport, + response_model=RmValidateReportActivity, response_model_exclude_none=True, description="Validate a Vulnerability Case by ID. (This is a stub implementation.)", ) -async def validate_case_by_id(id: str) -> RmValidateReport: +async def validate_case_by_id(id: str) -> RmValidateReportActivity: """Validate a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve and validate the case from a database. return vocab_examples.validate_report(verbose=True) @@ -91,11 +93,11 @@ async def validate_case_by_id(id: str) -> RmValidateReport: @router.put( "/{report_id}/invalid", - response_model=RmInvalidateReport, + response_model=RmInvalidateReportActivity, response_model_exclude_none=True, description="Invalidate a Vulnerability Case by ID. (This is a stub implementation.)", ) -async def invalidate_case_by_id(id: str) -> RmInvalidateReport: +async def invalidate_case_by_id(id: str) -> RmInvalidateReportActivity: """Invalidate a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve and invalidate the case from a database. return vocab_examples.invalidate_report(verbose=True) @@ -103,11 +105,11 @@ async def invalidate_case_by_id(id: str) -> RmInvalidateReport: @router.put( "/{report_id}/close", - response_model=RmCloseReport, + response_model=RmCloseReportActivity, response_model_exclude_none=True, description="Close a Vulnerability Case by ID. (This is a stub implementation.)", ) -async def close_case_by_id(id: str) -> RmCloseReport: +async def close_case_by_id(id: str) -> RmCloseReportActivity: """Close a VulnerabilityCase by ID. (This is a stub implementation.)""" # In a real implementation, you would retrieve and close the case from a database. return vocab_examples.close_report(verbose=True) diff --git a/vultron/api/v2/app.py b/vultron/api/v2/app.py index aa961013..e0f5e845 100644 --- a/vultron/api/v2/app.py +++ b/vultron/api/v2/app.py @@ -42,6 +42,10 @@ def configure_logging() -> None: @asynccontextmanager async def lifespan(application: FastAPI): configure_logging() + from vultron.api.v2.backend.inbox_handler import init_dispatcher + from vultron.adapters.driven.datalayer_tinydb import get_datalayer + + init_dispatcher(dl=get_datalayer()) yield diff --git a/vultron/api/v2/backend/handler_map.py b/vultron/api/v2/backend/handler_map.py new file mode 100644 index 00000000..e5029e7a --- /dev/null +++ b/vultron/api/v2/backend/handler_map.py @@ -0,0 +1,49 @@ +""" +Maps Message Semantics to their appropriate handlers (adapter layer). +""" + +from vultron.api.v2.backend import handlers as h +from vultron.core.models.events import MessageSemantics +from vultron.types import BehaviorHandler + +SEMANTICS_HANDLERS: dict[MessageSemantics, BehaviorHandler] = { + MessageSemantics.CREATE_REPORT: h.create_report, + MessageSemantics.SUBMIT_REPORT: h.submit_report, + MessageSemantics.VALIDATE_REPORT: h.validate_report, + MessageSemantics.INVALIDATE_REPORT: h.invalidate_report, + MessageSemantics.ACK_REPORT: h.ack_report, + MessageSemantics.CLOSE_REPORT: h.close_report, + MessageSemantics.CREATE_CASE: h.create_case, + MessageSemantics.UPDATE_CASE: h.update_case, + MessageSemantics.ENGAGE_CASE: h.engage_case, + MessageSemantics.DEFER_CASE: h.defer_case, + MessageSemantics.ADD_REPORT_TO_CASE: h.add_report_to_case, + MessageSemantics.SUGGEST_ACTOR_TO_CASE: h.suggest_actor_to_case, + MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE: h.accept_suggest_actor_to_case, + MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE: h.reject_suggest_actor_to_case, + MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER: h.offer_case_ownership_transfer, + MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER: h.accept_case_ownership_transfer, + MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER: h.reject_case_ownership_transfer, + MessageSemantics.INVITE_ACTOR_TO_CASE: h.invite_actor_to_case, + MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE: h.accept_invite_actor_to_case, + MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE: h.reject_invite_actor_to_case, + MessageSemantics.CREATE_EMBARGO_EVENT: h.create_embargo_event, + MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE: h.add_embargo_event_to_case, + MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE: h.remove_embargo_event_from_case, + MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE: h.announce_embargo_event_to_case, + MessageSemantics.INVITE_TO_EMBARGO_ON_CASE: h.invite_to_embargo_on_case, + MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE: h.accept_invite_to_embargo_on_case, + MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE: h.reject_invite_to_embargo_on_case, + MessageSemantics.CLOSE_CASE: h.close_case, + MessageSemantics.CREATE_CASE_PARTICIPANT: h.create_case_participant, + MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE: h.add_case_participant_to_case, + MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE: h.remove_case_participant_from_case, + MessageSemantics.CREATE_NOTE: h.create_note, + MessageSemantics.ADD_NOTE_TO_CASE: h.add_note_to_case, + MessageSemantics.REMOVE_NOTE_FROM_CASE: h.remove_note_from_case, + MessageSemantics.CREATE_CASE_STATUS: h.create_case_status, + MessageSemantics.ADD_CASE_STATUS_TO_CASE: h.add_case_status_to_case, + MessageSemantics.CREATE_PARTICIPANT_STATUS: h.create_participant_status, + MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT: h.add_participant_status_to_participant, + MessageSemantics.UNKNOWN: h.unknown, +} diff --git a/vultron/api/v2/backend/handlers/_base.py b/vultron/api/v2/backend/handlers/_base.py index 217802ac..209e5a38 100644 --- a/vultron/api/v2/backend/handlers/_base.py +++ b/vultron/api/v2/backend/handlers/_base.py @@ -4,14 +4,17 @@ import logging from functools import wraps +from typing import TYPE_CHECKING from vultron.api.v2.errors import ( VultronApiHandlerMissingSemanticError, VultronApiHandlerSemanticMismatchError, ) -from vultron.enums import MessageSemantics -from vultron.semantic_map import find_matching_semantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.types import DispatchEvent + +if TYPE_CHECKING: + from vultron.core.ports.datalayer import DataLayer logger = logging.getLogger(__name__) @@ -19,7 +22,7 @@ def verify_semantics(expected_semantic_type: MessageSemantics): def decorator(func): @wraps(func) - def wrapper(dispatchable: DispatchActivity): + def wrapper(dispatchable: DispatchEvent, dl: "DataLayer"): if not dispatchable.semantic_type: logger.error( "Dispatchable activity %s is missing semantic_type", @@ -27,21 +30,19 @@ def wrapper(dispatchable: DispatchActivity): ) raise VultronApiHandlerMissingSemanticError() - computed = find_matching_semantics(dispatchable.payload) - - if computed != expected_semantic_type: + if dispatchable.semantic_type != expected_semantic_type: logger.error( - "Dispatchable activity %s claims semantic_type %s (expected %s) that does not match its payload (%s)", + "Dispatchable activity %s has semantic_type %s but handler expects %s", dispatchable, dispatchable.semantic_type, expected_semantic_type, - computed, ) raise VultronApiHandlerSemanticMismatchError( - expected=expected_semantic_type, actual=computed + expected=expected_semantic_type, + actual=dispatchable.semantic_type, ) - return func(dispatchable) + return func(dispatchable, dl) return wrapper diff --git a/vultron/api/v2/backend/handlers/actor.py b/vultron/api/v2/backend/handlers/actor.py index a6c63295..da961098 100644 --- a/vultron/api/v2/backend/handlers/actor.py +++ b/vultron/api/v2/backend/handlers/actor.py @@ -1,411 +1,70 @@ -""" -Handler functions for case actor/participant invitation and suggestion activities. -""" +"""Handler functions for case actor activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.actor as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.SUGGEST_ACTOR_TO_CASE) -def suggest_actor_to_case(dispatchable: DispatchActivity) -> None: - """ - Process a RecommendActor (Offer(object=Actor, target=Case)) activity. - - This arrives in the *case owner's* inbox. The handler persists the - recommendation so the case owner can later accept or reject it. - Idempotent: if the recommendation is already stored, skips. - - Args: - dispatchable: DispatchActivity containing the RecommendActor - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - existing = dl.get(activity.as_type.value, activity.as_id) - if existing is not None: - logger.info( - "RecommendActor '%s' already stored — skipping (idempotent)", - activity.as_id, - ) - return None - - dl.create(activity) - logger.info( - "Stored actor recommendation '%s' (actor=%s, object=%s, target=%s)", - activity.as_id, - activity.actor, - activity.as_object, - activity.target, - ) - - except Exception as e: - logger.error( - "Error in suggest_actor_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def suggest_actor_to_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.suggest_actor_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE) -def accept_suggest_actor_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an AcceptActorRecommendation (Accept(object=RecommendActor)) activity. - - This arrives in the *recommending actor's* inbox after the case owner - accepts. The handler logs the acceptance and persists it. The actual - invitation of the accepted actor is a separate step by the case owner. - Idempotent: if already stored, skips. - - Args: - dispatchable: DispatchActivity containing the AcceptActorRecommendation - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - existing = dl.get(activity.as_type.value, activity.as_id) - if existing is not None: - logger.info( - "AcceptActorRecommendation '%s' already stored — skipping (idempotent)", - activity.as_id, - ) - return None - - dl.create(activity) - logger.info( - "Stored acceptance of actor recommendation '%s' (actor=%s, object=%s, target=%s)", - activity.as_id, - activity.actor, - activity.as_object, - activity.target, - ) - - except Exception as e: - logger.error( - "Error in accept_suggest_actor_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def accept_suggest_actor_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.accept_suggest_actor_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE) -def reject_suggest_actor_to_case(dispatchable: DispatchActivity) -> None: - """ - Process a RejectActorRecommendation (Reject(object=RecommendActor)) activity. - - This arrives in the *recommending actor's* inbox after the case owner - rejects the recommendation. No state change is required. - - Args: - dispatchable: DispatchActivity containing the RejectActorRecommendation - """ - activity = dispatchable.payload - - try: - object_ref = activity.as_object - object_id = ( - object_ref.as_id - if hasattr(object_ref, "as_id") - else str(object_ref) - ) - logger.info( - "Actor '%s' rejected recommendation to add actor '%s' to case", - activity.actor, - object_id, - ) - - except Exception as e: - logger.error( - "Error in reject_suggest_actor_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def reject_suggest_actor_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.reject_suggest_actor_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER) -def offer_case_ownership_transfer(dispatchable: DispatchActivity) -> None: - """ - Process an OfferCaseOwnershipTransfer (Offer(object=Case, target=Actor)) activity. - - This arrives in the *proposed new owner's* inbox. The handler persists - the offer so the target can later accept or reject it. - Idempotent: if the offer is already stored, skips. - - Args: - dispatchable: DispatchActivity containing the OfferCaseOwnershipTransfer - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - existing = dl.get(activity.as_type.value, activity.as_id) - if existing is not None: - logger.info( - "OfferCaseOwnershipTransfer '%s' already stored — skipping (idempotent)", - activity.as_id, - ) - return None - - dl.create(activity) - logger.info( - "Stored ownership transfer offer '%s' (actor=%s, target=%s)", - activity.as_id, - activity.actor, - activity.target, - ) - - except Exception as e: - logger.error( - "Error in offer_case_ownership_transfer for activity %s: %s", - activity.as_id, - str(e), - ) +def offer_case_ownership_transfer( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.offer_case_ownership_transfer(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER) -def accept_case_ownership_transfer(dispatchable: DispatchActivity) -> None: - """ - Process an AcceptCaseOwnershipTransfer (Accept(object=OfferCaseOwnershipTransfer)) activity. - - This arrives in the *current owner's* inbox after the proposed new owner - accepts. The handler rehydrates the offer to retrieve the case, then - updates the case's attributed_to field to the new owner. - Idempotent: if the case already belongs to the new owner, skips. - - Args: - dispatchable: DispatchActivity containing the AcceptCaseOwnershipTransfer - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - offer = rehydrate(obj=activity.as_object) - case = rehydrate(obj=offer.as_object) - - new_owner_id = ( - activity.actor.as_id - if hasattr(activity.actor, "as_id") - else str(activity.actor) - ) - case_id = case.as_id - - current_owner_id = ( - case.attributed_to.as_id - if hasattr(case.attributed_to, "as_id") - else str(case.attributed_to) if case.attributed_to else None - ) - if current_owner_id == new_owner_id: - logger.info( - "Case '%s' already owned by '%s' — skipping (idempotent)", - case_id, - new_owner_id, - ) - return None - - case.attributed_to = new_owner_id - dl.update(case_id, object_to_record(case)) - logger.info( - "Transferred ownership of case '%s' from '%s' to '%s'", - case_id, - current_owner_id, - new_owner_id, - ) - - except Exception as e: - logger.error( - "Error in accept_case_ownership_transfer for activity %s: %s", - activity.as_id, - str(e), - ) +def accept_case_ownership_transfer( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.accept_case_ownership_transfer(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER) -def reject_case_ownership_transfer(dispatchable: DispatchActivity) -> None: - """ - Process a RejectCaseOwnershipTransfer (Reject(object=OfferCaseOwnershipTransfer)) activity. - - This arrives in the *current owner's* inbox after the proposed new owner - rejects. Case ownership is unchanged. No state change required. - - Args: - dispatchable: DispatchActivity containing the RejectCaseOwnershipTransfer - """ - activity = dispatchable.payload - - try: - offer_ref = activity.as_object - offer_id = ( - offer_ref.as_id if hasattr(offer_ref, "as_id") else str(offer_ref) - ) - logger.info( - "Actor '%s' rejected ownership transfer offer '%s' — ownership unchanged", - activity.actor, - offer_id, - ) - - except Exception as e: - logger.error( - "Error in reject_case_ownership_transfer for activity %s: %s", - activity.as_id, - str(e), - ) +def reject_case_ownership_transfer( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.reject_case_ownership_transfer(dispatchable.payload, dl) @verify_semantics(MessageSemantics.INVITE_ACTOR_TO_CASE) -def invite_actor_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an Invite(actor=CaseOwner, object=Actor, target=Case) activity. - - This arrives in the *invited actor's* inbox. The handler persists the - Invite so that the actor can later accept or reject it. - - Args: - dispatchable: DispatchActivity containing the RmInviteToCase activity - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - existing = dl.get(activity.as_type.value, activity.as_id) - if existing is not None: - logger.info( - "Invite '%s' already stored — skipping (idempotent)", - activity.as_id, - ) - return None - - dl.create(activity) - logger.info( - "Stored invite '%s' (actor=%s, target=%s)", - activity.as_id, - activity.as_actor, - activity.target, - ) - - except Exception as e: - logger.error( - "Error in invite_actor_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def invite_actor_to_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.invite_actor_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE) -def accept_invite_actor_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an Accept(object=RmInviteToCase) activity. - - This arrives in the *case owner's* inbox after the invited actor accepts. - The handler creates a CaseParticipant for the invited actor and adds them - to the case's participant list. Idempotent: if the participant is already - in the case, the handler returns without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the RmAcceptInviteToCase - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.as_vocab.objects.case_participant import CaseParticipant - - activity = dispatchable.payload - - try: - invite = rehydrate(obj=activity.as_object) - case = rehydrate(obj=invite.target) - invitee_ref = invite.as_object - invitee_id = ( - invitee_ref.as_id - if hasattr(invitee_ref, "as_id") - else str(invitee_ref) - ) - case_id = case.as_id - - dl = get_datalayer() - - existing_ids = [ - (p.as_id if hasattr(p, "as_id") else p) - for p in case.case_participants - ] - if invitee_id in existing_ids: - logger.info( - "Actor '%s' already participant in case '%s' — skipping (idempotent)", - invitee_id, - case_id, - ) - return None - - participant = CaseParticipant( - id=f"{case_id}/participants/{invitee_id.split('/')[-1]}", - attributed_to=invitee_id, - context=case_id, - ) - dl.create(participant) - - case.case_participants.append(participant.as_id) - dl.update(case_id, object_to_record(case)) - - logger.info( - "Added participant '%s' to case '%s' via accepted invite", - invitee_id, - case_id, - ) - - except Exception as e: - logger.error( - "Error in accept_invite_actor_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def accept_invite_actor_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.accept_invite_actor_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE) -def reject_invite_actor_to_case(dispatchable: DispatchActivity) -> None: - """ - Process a Reject(object=RmInviteToCase) activity. - - This arrives in the *case owner's* inbox. The handler logs the rejection; - no state change is required. - - Args: - dispatchable: DispatchActivity containing the RmRejectInviteToCase - """ - activity = dispatchable.payload - - try: - invite_ref = activity.as_object - invite_id = ( - invite_ref.as_id - if hasattr(invite_ref, "as_id") - else str(invite_ref) - ) - logger.info( - "Actor '%s' rejected invitation '%s'", - activity.as_actor, - invite_id, - ) - - except Exception as e: - logger.error( - "Error in reject_invite_actor_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def reject_invite_actor_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.reject_invite_actor_to_case(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/case.py b/vultron/api/v2/backend/handlers/case.py index 319c5487..a1952988 100644 --- a/vultron/api/v2/backend/handlers/case.py +++ b/vultron/api/v2/backend/handlers/case.py @@ -1,377 +1,41 @@ -""" -Handler functions for vulnerability case activities. -""" +"""Handler functions for vulnerability case activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.case as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.CREATE_CASE) -def create_case(dispatchable: DispatchActivity) -> None: - """ - Process a CreateCase activity (Create(VulnerabilityCase)). - - Persists the new VulnerabilityCase to the DataLayer, creates the - associated CaseActor (CM-02-001), and emits a CreateCase activity to - the actor outbox. Idempotent: re-processing an already-stored case - succeeds without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the as_Create with - VulnerabilityCase object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.behaviors.bridge import BTBridge - from vultron.behaviors.case.create_tree import create_create_case_tree - - activity = dispatchable.payload - - try: - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - case = rehydrate(obj=activity.as_object) - case_id = case.as_id - - logger.info("Actor '%s' creates case '%s'", actor_id, case_id) - - dl = get_datalayer() - bridge = BTBridge(datalayer=dl) - tree = create_create_case_tree(case_obj=case, actor_id=actor_id) - result = bridge.execute_with_setup( - tree=tree, actor_id=actor_id, activity=activity - ) - - if result.status.name != "SUCCESS": - logger.warning( - "CreateCaseBT did not succeed for actor '%s' / case '%s': %s", - actor_id, - case_id, - result.feedback_message, - ) - - except Exception as e: - logger.error( - "Error in create_case for activity %s: %s", - activity.as_id, - str(e), - ) +def create_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.create_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ENGAGE_CASE) -def engage_case(dispatchable: DispatchActivity) -> None: - """ - Process an RmEngageCase activity (Join(VulnerabilityCase)). - - The sending actor has decided to engage the case (RM → ACCEPTED). Records - their RM state transition in their CaseParticipant.participant_status. - - RM is participant-specific: each CaseParticipant tracks its own RM state - independently of other participants in the same case. - - Args: - dispatchable: DispatchActivity containing the as_Join with - VulnerabilityCase object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.behaviors.bridge import BTBridge - from vultron.behaviors.report.prioritize_tree import ( - create_engage_case_tree, - ) - - activity = dispatchable.payload - - try: - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - case = rehydrate(obj=activity.as_object) - case_id = case.as_id - - logger.info( - "Actor '%s' engages case '%s' (RM → ACCEPTED)", actor_id, case_id - ) - - dl = get_datalayer() - bridge = BTBridge(datalayer=dl) - tree = create_engage_case_tree(case_id=case_id, actor_id=actor_id) - result = bridge.execute_with_setup( - tree=tree, actor_id=actor_id, activity=activity - ) - - if result.status.name != "SUCCESS": - logger.warning( - "EngageCaseBT did not succeed for actor '%s' / case '%s': %s", - actor_id, - case_id, - result.feedback_message, - ) - - except Exception as e: - logger.error( - "Error in engage_case for activity %s: %s", - activity.as_id, - str(e), - ) - - return None +def engage_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.engage_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.DEFER_CASE) -def defer_case(dispatchable: DispatchActivity) -> None: - """ - Process an RmDeferCase activity (Ignore(VulnerabilityCase)). - - The sending actor has decided to defer the case (RM → DEFERRED). Records - their RM state transition in their CaseParticipant.participant_status. - - RM is participant-specific: each CaseParticipant tracks its own RM state - independently of other participants in the same case. - - Args: - dispatchable: DispatchActivity containing the as_Ignore with - VulnerabilityCase object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.behaviors.bridge import BTBridge - from vultron.behaviors.report.prioritize_tree import create_defer_case_tree - - activity = dispatchable.payload - - try: - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - case = rehydrate(obj=activity.as_object) - case_id = case.as_id - - logger.info( - "Actor '%s' defers case '%s' (RM → DEFERRED)", actor_id, case_id - ) - - dl = get_datalayer() - bridge = BTBridge(datalayer=dl) - tree = create_defer_case_tree(case_id=case_id, actor_id=actor_id) - result = bridge.execute_with_setup( - tree=tree, actor_id=actor_id, activity=activity - ) - - if result.status.name != "SUCCESS": - logger.warning( - "DeferCaseBT did not succeed for actor '%s' / case '%s': %s", - actor_id, - case_id, - result.feedback_message, - ) - - except Exception as e: - logger.error( - "Error in defer_case for activity %s: %s", - activity.as_id, - str(e), - ) - - return None +def defer_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.defer_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ADD_REPORT_TO_CASE) -def add_report_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an AddReportToCase activity - (Add(VulnerabilityReport, target=VulnerabilityCase)). - - Appends the report reference to the case's vulnerability_reports list - and persists the updated case to the DataLayer. Idempotent: re-adding a - report already in the case succeeds without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the as_Add with - VulnerabilityReport object and VulnerabilityCase target - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - report = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - report_id = report.as_id - case_id = case.as_id - - dl = get_datalayer() - - existing_report_ids = [ - (r.as_id if hasattr(r, "as_id") else r) - for r in case.vulnerability_reports - ] - if report_id in existing_report_ids: - logger.info( - "Report '%s' already in case '%s' — skipping (idempotent)", - report_id, - case_id, - ) - return None - - case.vulnerability_reports.append(report_id) - dl.update(case_id, object_to_record(case)) - - logger.info("Added report '%s' to case '%s'", report_id, case_id) - - except Exception as e: - logger.error( - "Error in add_report_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def add_report_to_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.add_report_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.CLOSE_CASE) -def close_case(dispatchable: DispatchActivity) -> None: - """ - Process a CloseCase activity (Leave(VulnerabilityCase)). - - Records that the sending actor is leaving/closing their participation - in the case. Emits an RmCloseCase activity to the actor outbox. - - Args: - dispatchable: DispatchActivity containing the as_Leave with - VulnerabilityCase object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.as_vocab.activities.case import RmCloseCase - - activity = dispatchable.payload - - try: - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - case = rehydrate(obj=activity.as_object) - case_id = case.as_id - - logger.info("Actor '%s' is closing case '%s'", actor_id, case_id) - - dl = get_datalayer() - - close_activity = RmCloseCase( - actor=actor_id, - object=case_id, - ) - try: - dl.create(close_activity) - logger.info( - "Created RmCloseCase activity %s", close_activity.as_id - ) - except ValueError: - logger.info( - "RmCloseCase activity for case '%s' already exists" - " — skipping (idempotent)", - case_id, - ) - return None - - actor_obj = dl.read(actor_id) - if actor_obj is not None and hasattr(actor_obj, "outbox"): - actor_obj.outbox.items.append(close_activity.as_id) - dl.update(actor_id, object_to_record(actor_obj)) - logger.info( - "Added RmCloseCase activity %s to actor %s outbox", - close_activity.as_id, - actor_id, - ) - - except Exception as e: - logger.error( - "Error in close_case for activity %s: %s", - activity.as_id, - str(e), - ) +def close_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.close_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.UPDATE_CASE) -def update_case(dispatchable: DispatchActivity) -> None: - """ - Process an UpdateCase activity (Update(VulnerabilityCase)). - - Applies scalar field updates from the activity's object to the stored - VulnerabilityCase in the DataLayer. Restricted to the case owner: if - the sending actor is not the case owner, logs a WARNING and skips. - Idempotent: last-write-wins on scalar fields. - - Args: - dispatchable: DispatchActivity containing the as_Update with - VulnerabilityCase object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase - - activity = dispatchable.payload - - try: - actor_id = ( - activity.actor.as_id - if hasattr(activity.actor, "as_id") - else str(activity.actor) - ) - incoming = rehydrate(obj=activity.as_object) - case_id = ( - incoming.as_id if hasattr(incoming, "as_id") else str(incoming) - ) - - dl = get_datalayer() - stored_case = dl.read(case_id) - if stored_case is None: - logger.warning( - "update_case: case '%s' not found in DataLayer — skipping", - case_id, - ) - return None - - owner_id = ( - stored_case.attributed_to.as_id - if hasattr(stored_case.attributed_to, "as_id") - else ( - str(stored_case.attributed_to) - if stored_case.attributed_to - else None - ) - ) - if owner_id != actor_id: - logger.warning( - "update_case: actor '%s' is not the owner of case '%s'" - " — skipping update", - actor_id, - case_id, - ) - return None - - if isinstance(incoming, VulnerabilityCase): - for field in ("name", "summary", "content"): - value = getattr(incoming, field, None) - if value is not None: - setattr(stored_case, field, value) - dl.update(case_id, object_to_record(stored_case)) - logger.info("Actor '%s' updated case '%s'", actor_id, case_id) - else: - logger.info( - "update_case: object for case '%s' is a reference only" - " — no fields to apply", - case_id, - ) - - except Exception as e: - logger.error( - "Error in update_case for activity %s: %s", - activity.as_id, - str(e), - ) +def update_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.update_case(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/embargo.py b/vultron/api/v2/backend/handlers/embargo.py index 3be2b853..97e75452 100644 --- a/vultron/api/v2/backend/handlers/embargo.py +++ b/vultron/api/v2/backend/handlers/embargo.py @@ -1,350 +1,58 @@ -""" -Handler functions for embargo management activities. -""" +"""Handler functions for embargo management activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.embargo as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.CREATE_EMBARGO_EVENT) -def create_embargo_event(dispatchable: DispatchActivity) -> None: - """ - Process a Create(EmbargoEvent) activity. - - Persists the EmbargoEvent to the DataLayer so it can be referenced by - subsequent add/activate/announce embargo activities. - - Args: - dispatchable: DispatchActivity containing the Create(EmbargoEvent) - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - embargo = activity.as_object - - existing = dl.get(embargo.as_type.value, embargo.as_id) - if existing is not None: - logger.info( - "EmbargoEvent '%s' already stored — skipping (idempotent)", - embargo.as_id, - ) - return None - - dl.create(embargo) - logger.info("Stored EmbargoEvent '%s'", embargo.as_id) - - except Exception as e: - logger.error( - "Error in create_embargo_event for activity %s: %s", - activity.as_id, - str(e), - ) +def create_embargo_event(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.create_embargo_event(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE) -def add_embargo_event_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an Add(EmbargoEvent, target=VulnerabilityCase) or - ActivateEmbargo(EmbargoEvent, target=VulnerabilityCase) activity. - - Links the embargo event to the case and sets the case EM state to ACTIVE. - Idempotent: if the case already has this embargo active, skips. - - Args: - dispatchable: DispatchActivity containing the Add/ActivateEmbargo - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - embargo = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - - embargo_id = ( - embargo.as_id if hasattr(embargo, "as_id") else str(embargo) - ) - case_id = case.as_id if hasattr(case, "as_id") else str(case) - - current_embargo_id = ( - case.active_embargo.as_id - if hasattr(case.active_embargo, "as_id") - else ( - str(case.active_embargo) - if case.active_embargo is not None - else None - ) - ) - if current_embargo_id == embargo_id: - logger.info( - "Case '%s' already has embargo '%s' active — skipping (idempotent)", - case_id, - embargo_id, - ) - return None - - case.set_embargo( - embargo.as_id if hasattr(embargo, "as_id") else embargo - ) - dl.update(case_id, object_to_record(case)) - logger.info( - "Activated embargo '%s' on case '%s'", - embargo_id, - case_id, - ) - - except Exception as e: - logger.error( - "Error in add_embargo_event_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def add_embargo_event_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.add_embargo_event_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE) -def remove_embargo_event_from_case(dispatchable: DispatchActivity) -> None: - """ - Process a Remove(EmbargoEvent, origin=VulnerabilityCase) activity. - - Clears the active embargo from the case and sets EM state accordingly. - Per ActivityStreams spec, the `origin` field holds the context from which - the object is removed. - - Args: - dispatchable: DispatchActivity containing the RemoveEmbargoFromCase - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.bt.embargo_management.states import EM - - activity = dispatchable.payload - - try: - dl = get_datalayer() - case = rehydrate(obj=activity.origin) - embargo = activity.as_object - - embargo_id = ( - embargo.as_id if hasattr(embargo, "as_id") else str(embargo) - ) - case_id = case.as_id - - current_embargo_id = ( - case.active_embargo.as_id - if hasattr(case.active_embargo, "as_id") - else ( - str(case.active_embargo) - if case.active_embargo is not None - else None - ) - ) - if current_embargo_id != embargo_id: - logger.info( - "Case '%s' does not have embargo '%s' active — skipping", - case_id, - embargo_id, - ) - return None - - case.active_embargo = None - case.current_status.em_state = EM.EMBARGO_MANAGEMENT_NONE - dl.update(case_id, object_to_record(case)) - logger.info( - "Removed embargo '%s' from case '%s'", - embargo_id, - case_id, - ) - - except Exception as e: - logger.error( - "Error in remove_embargo_event_from_case for activity %s: %s", - activity.as_id, - str(e), - ) +def remove_embargo_event_from_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.remove_embargo_event_from_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE) -def announce_embargo_event_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an Announce(EmbargoEvent, context=VulnerabilityCase) activity. - - Records the announcement in the case activity log. The AnnounceEmbargo - activity informs case participants of the current embargo status. - - Args: - dispatchable: DispatchActivity containing the AnnounceEmbargo - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - case = rehydrate(obj=activity.context) - case_id = case.as_id - - logger.info( - "Received embargo announcement '%s' on case '%s'", - activity.as_id, - case_id, - ) - - except Exception as e: - logger.error( - "Error in announce_embargo_event_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def announce_embargo_event_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.announce_embargo_event_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.INVITE_TO_EMBARGO_ON_CASE) -def invite_to_embargo_on_case(dispatchable: DispatchActivity) -> None: - """ - Process an EmProposeEmbargo (Invite(EmbargoEvent, context=VulnerabilityCase)) activity. - - Persists the proposal so participants can later accept or reject it. - This arrives in the *invitee's* inbox. Idempotent: if the proposal is - already stored, skips. - - Args: - dispatchable: DispatchActivity containing the EmProposeEmbargo - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - existing = dl.get(activity.as_type.value, activity.as_id) - if existing is not None: - logger.info( - "EmProposeEmbargo '%s' already stored — skipping (idempotent)", - activity.as_id, - ) - return None - - dl.create(activity) - logger.info( - "Stored embargo proposal '%s' (actor=%s, context=%s)", - activity.as_id, - activity.as_actor, - activity.context, - ) - - except Exception as e: - logger.error( - "Error in invite_to_embargo_on_case for activity %s: %s", - activity.as_id, - str(e), - ) +def invite_to_embargo_on_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.invite_to_embargo_on_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE) -def accept_invite_to_embargo_on_case(dispatchable: DispatchActivity) -> None: - """ - Process an EmAcceptEmbargo (Accept(object=EmProposeEmbargo)) activity. - - This arrives in the *proposer's* inbox. The handler rehydrates the - proposal, retrieves the proposed EmbargoEvent, and activates it on the - case via set_embargo(). Idempotent: if the case already has this embargo - active, skips. - - Args: - dispatchable: DispatchActivity containing the EmAcceptEmbargo - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - proposal = rehydrate(obj=activity.as_object) - embargo = rehydrate(obj=proposal.as_object) - case = rehydrate(obj=proposal.context) - - embargo_id = embargo.as_id - case_id = case.as_id - - current_embargo_id = ( - case.active_embargo.as_id - if hasattr(case.active_embargo, "as_id") - else ( - str(case.active_embargo) - if case.active_embargo is not None - else None - ) - ) - if current_embargo_id == embargo_id: - logger.info( - "Case '%s' already has embargo '%s' active — skipping (idempotent)", - case_id, - embargo_id, - ) - return None - - case.set_embargo( - embargo.as_id if hasattr(embargo, "as_id") else embargo - ) - dl.update(case_id, object_to_record(case)) - logger.info( - "Accepted embargo proposal '%s'; activated embargo '%s' on case '%s'", - proposal.as_id, - embargo_id, - case_id, - ) - - except Exception as e: - logger.error( - "Error in accept_invite_to_embargo_on_case for activity %s: %s", - activity.as_id, - str(e), - ) +def accept_invite_to_embargo_on_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.accept_invite_to_embargo_on_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE) -def reject_invite_to_embargo_on_case(dispatchable: DispatchActivity) -> None: - """ - Process an EmRejectEmbargo (Reject(object=EmProposeEmbargo)) activity. - - This arrives in the *proposer's* inbox. The handler logs the rejection; - no state change is required. - - Args: - dispatchable: DispatchActivity containing the EmRejectEmbargo - """ - activity = dispatchable.payload - - try: - proposal_ref = activity.as_object - proposal_id = ( - proposal_ref.as_id - if hasattr(proposal_ref, "as_id") - else str(proposal_ref) - ) - logger.info( - "Actor '%s' rejected embargo proposal '%s'", - activity.as_actor, - proposal_id, - ) - - except Exception as e: - logger.error( - "Error in reject_invite_to_embargo_on_case for activity %s: %s", - activity.as_id, - str(e), - ) +def reject_invite_to_embargo_on_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.reject_invite_to_embargo_on_case(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/note.py b/vultron/api/v2/backend/handlers/note.py index e6ecbbc0..a3aca27d 100644 --- a/vultron/api/v2/backend/handlers/note.py +++ b/vultron/api/v2/backend/handlers/note.py @@ -1,149 +1,26 @@ -""" -Handler functions for case note activities. -""" +"""Handler functions for case note activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.note as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.CREATE_NOTE) -def create_note(dispatchable: DispatchActivity) -> None: - """ - Process a Create(Note) activity. - - Persists the Note to the DataLayer so it can be referenced by - subsequent add_note_to_case activities. Idempotent: if a Note with - the same ID already exists, the handler skips creation (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the Create(Note) - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - note = activity.as_object - - existing = dl.get(note.as_type.value, note.as_id) - if existing is not None: - logger.info( - "Note '%s' already stored — skipping (idempotent)", note.as_id - ) - return None - - dl.create(note) - logger.info("Stored Note '%s'", note.as_id) - - except Exception as e: - logger.error( - "Error in create_note for activity %s: %s", - activity.as_id, - str(e), - ) +def create_note(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.create_note(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ADD_NOTE_TO_CASE) -def add_note_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an Add(Note, target=VulnerabilityCase) activity. - - Appends the note reference to the case's notes list and persists the - updated case. Idempotent: re-adding a note already in the case - succeeds without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the Add(Note, target=case) - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - note = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - note_id = note.as_id if hasattr(note, "as_id") else str(note) - case_id = case.as_id - - existing_ids = [ - (n.as_id if hasattr(n, "as_id") else n) for n in case.notes - ] - if note_id in existing_ids: - logger.info( - "Note '%s' already in case '%s' — skipping (idempotent)", - note_id, - case_id, - ) - return None - - case.notes.append(note_id) - dl.update(case_id, object_to_record(case)) - logger.info("Added note '%s' to case '%s'", note_id, case_id) - - except Exception as e: - logger.error( - "Error in add_note_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def add_note_to_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.add_note_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REMOVE_NOTE_FROM_CASE) -def remove_note_from_case(dispatchable: DispatchActivity) -> None: - """ - Process a Remove(Note, target=VulnerabilityCase) activity. - - Removes the note reference from the case's notes list and persists - the updated case. Idempotent: if the note is not in the case, - the handler returns without error (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the Remove(Note, target=case) - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - note = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - note_id = note.as_id if hasattr(note, "as_id") else str(note) - case_id = case.as_id - - existing_ids = [ - (n.as_id if hasattr(n, "as_id") else n) for n in case.notes - ] - if note_id not in existing_ids: - logger.info( - "Note '%s' not in case '%s' — skipping (idempotent)", - note_id, - case_id, - ) - return None - - case.notes = [ - n - for n in case.notes - if (n.as_id if hasattr(n, "as_id") else n) != note_id - ] - dl.update(case_id, object_to_record(case)) - logger.info("Removed note '%s' from case '%s'", note_id, case_id) - - except Exception as e: - logger.error( - "Error in remove_note_from_case for activity %s: %s", - activity.as_id, - str(e), - ) +def remove_note_from_case(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.remove_note_from_case(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/participant.py b/vultron/api/v2/backend/handlers/participant.py index 48fde6ce..dc19e7dd 100644 --- a/vultron/api/v2/backend/handlers/participant.py +++ b/vultron/api/v2/backend/handlers/participant.py @@ -1,171 +1,32 @@ -""" -Handler functions for case participant management activities. -""" +"""Handler functions for case participant management activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.case_participant as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.CREATE_CASE_PARTICIPANT) -def create_case_participant(dispatchable: DispatchActivity) -> None: - """ - Process a Create(CaseParticipant) activity. - - Persists the new CaseParticipant to the DataLayer. Because - CaseParticipant uses `attributed_to` (a standard as_Object field) for - the actor reference, the full object survives inbox deserialization. - Idempotent: if a participant with the same ID already exists, the - handler logs at INFO and returns without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the as_Create with - CaseParticipant object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - participant = rehydrate(obj=activity.as_object) - participant_id = participant.as_id - - dl = get_datalayer() - - existing = dl.get(participant.as_type.value, participant_id) - if existing is not None: - logger.info( - "Participant '%s' already exists — skipping (idempotent)", - participant_id, - ) - return None - - dl.create(participant) - logger.info("Created participant '%s'", participant_id) - - except Exception as e: - logger.error( - "Error in create_case_participant for activity %s: %s", - activity.as_id, - str(e), - ) +def create_case_participant( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.create_case_participant(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE) -def add_case_participant_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an AddParticipantToCase activity - (Add(CaseParticipant, target=VulnerabilityCase)). - - Appends the participant reference to the case's case_participants list - and persists the updated case to the DataLayer. Idempotent: re-adding a - participant already in the case succeeds without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the as_Add with - CaseParticipant object and VulnerabilityCase target - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - participant = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - participant_id = participant.as_id - case_id = case.as_id - - dl = get_datalayer() - - existing_ids = [ - (p.as_id if hasattr(p, "as_id") else p) - for p in case.case_participants - ] - if participant_id in existing_ids: - logger.info( - "Participant '%s' already in case '%s' — skipping (idempotent)", - participant_id, - case_id, - ) - return None - - case.case_participants.append(participant_id) - dl.update(case_id, object_to_record(case)) - - logger.info( - "Added participant '%s' to case '%s'", participant_id, case_id - ) - - except Exception as e: - logger.error( - "Error in add_case_participant_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def add_case_participant_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.add_case_participant_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE) -def remove_case_participant_from_case(dispatchable: DispatchActivity) -> None: - """ - Process a Remove(CaseParticipant, target=VulnerabilityCase) activity. - - Removes the participant reference from the case's case_participants list - and persists the updated case. Idempotent: if the participant is not in - the case, the handler returns without error (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the as_Remove with - CaseParticipant object and VulnerabilityCase target - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - participant = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - participant_id = participant.as_id - case_id = case.as_id - - dl = get_datalayer() - - existing_ids = [ - (p.as_id if hasattr(p, "as_id") else p) - for p in case.case_participants - ] - if participant_id not in existing_ids: - logger.info( - "Participant '%s' not in case '%s' — skipping (idempotent)", - participant_id, - case_id, - ) - return None - - case.case_participants = [ - p - for p in case.case_participants - if (p.as_id if hasattr(p, "as_id") else p) != participant_id - ] - dl.update(case_id, object_to_record(case)) - - logger.info( - "Removed participant '%s' from case '%s'", - participant_id, - case_id, - ) - - except Exception as e: - logger.error( - "Error in remove_case_participant_from_case for activity %s: %s", - activity.as_id, - str(e), - ) +def remove_case_participant_from_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.remove_case_participant_from_case(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/report.py b/vultron/api/v2/backend/handlers/report.py index 56f09dd1..be7fca49 100644 --- a/vultron/api/v2/backend/handlers/report.py +++ b/vultron/api/v2/backend/handlers/report.py @@ -1,461 +1,41 @@ -""" -Handler functions for vulnerability report activities. -""" +"""Handler functions for vulnerability report activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.report as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.CREATE_REPORT) -def create_report(dispatchable: DispatchActivity) -> None: - """ - Process a CreateReport activity (Create(VulnerabilityReport)). - - Stores the VulnerabilityReport object in the data layer and the Create activity. - - Args: - dispatchable: DispatchActivity containing the as_Create with VulnerabilityReport object - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.as_vocab.objects.vulnerability_report import ( - VulnerabilityReport, - ) - - activity = dispatchable.payload - - # Extract the created report - created_obj = activity.as_object - if not isinstance(created_obj, VulnerabilityReport): - logger.error( - "Expected VulnerabilityReport in create_report, got %s", - type(created_obj).__name__, - ) - return None - - actor_id = activity.actor - logger.info( - "Actor '%s' creates VulnerabilityReport '%s' (ID: %s)", - actor_id, - created_obj.name, - created_obj.as_id, - ) - - # Get data layer - dl = get_datalayer() - - # Store the report object - try: - dl.create(created_obj) - logger.info( - "Stored VulnerabilityReport with ID: %s", created_obj.as_id - ) - except ValueError as e: - logger.warning( - "VulnerabilityReport %s already exists: %s", created_obj.as_id, e - ) - - # Store the create activity - try: - dl.create(activity) - logger.info("Stored CreateReport activity with ID: %s", activity.as_id) - except ValueError as e: - logger.warning( - "CreateReport activity %s already exists: %s", activity.as_id, e - ) - - return None +def create_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.create_report(dispatchable.payload, dl) @verify_semantics(MessageSemantics.SUBMIT_REPORT) -def submit_report(dispatchable: DispatchActivity) -> None: - """ - Process a SubmitReport activity (Offer(VulnerabilityReport)). - - Stores both the VulnerabilityReport object and the Offer activity in the data layer. - - Args: - dispatchable: DispatchActivity containing the as_Offer with VulnerabilityReport object - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.as_vocab.objects.vulnerability_report import ( - VulnerabilityReport, - ) - - activity = dispatchable.payload - - # Extract the offered report - offered_obj = activity.as_object - if not isinstance(offered_obj, VulnerabilityReport): - logger.error( - "Expected VulnerabilityReport in submit_report, got %s", - type(offered_obj).__name__, - ) - return None - - actor_id = activity.actor - logger.info( - "Actor '%s' submits VulnerabilityReport '%s' (ID: %s)", - actor_id, - offered_obj.name, - offered_obj.as_id, - ) - - # Get data layer - dl = get_datalayer() - - # Store the report object - try: - dl.create(offered_obj) - logger.info( - "Stored VulnerabilityReport with ID: %s", offered_obj.as_id - ) - except ValueError as e: - logger.warning( - "VulnerabilityReport %s already exists: %s", offered_obj.as_id, e - ) - - # Store the offer activity - try: - dl.create(activity) - logger.info("Stored SubmitReport activity with ID: %s", activity.as_id) - except ValueError as e: - logger.warning( - "SubmitReport activity %s already exists: %s", activity.as_id, e - ) - - return None +def submit_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.submit_report(dispatchable.payload, dl) @verify_semantics(MessageSemantics.VALIDATE_REPORT) -def validate_report(dispatchable: DispatchActivity) -> None: - """ - Process a ValidateReport activity (Accept(Offer(VulnerabilityReport))). - - Uses behavior tree execution to orchestrate report validation workflow, - including status updates, case creation, and activity generation. - - Args: - dispatchable: DispatchActivity containing the as_Accept with Offer object - """ - from py_trees.common import Status - - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.as_vocab.objects.vulnerability_report import ( - VulnerabilityReport, - ) - from vultron.behaviors.bridge import BTBridge - from vultron.behaviors.report.validate_tree import ( - create_validate_report_tree, - ) - - activity = dispatchable.payload - - # Rehydrate the accepted offer and report (validation phase) - try: - accepted_offer = rehydrate(activity.as_object) - accepted_report = rehydrate(accepted_offer.as_object) - except (ValueError, KeyError) as e: - logger.error( - "Failed to rehydrate offer or report in validate_report: %s", e - ) - return None - - # Verify we have a VulnerabilityReport - if not isinstance(accepted_report, VulnerabilityReport): - logger.error( - "Expected VulnerabilityReport in validate_report, got %s", - type(accepted_report).__name__, - ) - return None - - # Rehydrate actor - try: - actor = rehydrate(activity.actor) - actor_id = actor.as_id - except (ValueError, KeyError) as e: - logger.error("Failed to rehydrate actor in validate_report: %s", e) - return None - - logger.info( - "Actor '%s' validates VulnerabilityReport '%s' via BT execution", - actor_id, - accepted_report.as_id, - ) - - # Delegate to behavior tree for workflow orchestration - report_id = accepted_report.as_id - offer_id = accepted_offer.as_id - - dl = get_datalayer() - bridge = BTBridge(datalayer=dl) - - # Create and execute validation tree - tree = create_validate_report_tree(report_id=report_id, offer_id=offer_id) - - # Log tree structure for visibility (DEBUG level logs structure, can be enabled if needed) - tree_viz = BTBridge.get_tree_visualization(tree, show_status=False) - logger.debug("Validation BT structure:\n%s", tree_viz) - - result = bridge.execute_with_setup( - tree, actor_id=actor_id, activity=activity - ) - - # Handle BT execution results with detailed feedback - if result.status == Status.SUCCESS: - logger.info( - "✓ BT execution succeeded for report validation: %s (feedback: %s)", - report_id, - result.feedback_message or "none", - ) - elif result.status == Status.FAILURE: - logger.error( - "✗ BT execution failed for report validation: %s - %s", - report_id, - result.feedback_message, - ) - if result.errors: - for error in result.errors: - logger.error(" - %s", error) - else: - logger.warning( - "⚠ BT execution incomplete for report validation: %s (status=%s)", - report_id, - result.status, - ) - - return None +def validate_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.validate_report(dispatchable.payload, dl) @verify_semantics(MessageSemantics.INVALIDATE_REPORT) -def invalidate_report(dispatchable: DispatchActivity) -> None: - """ - Process an InvalidateReport activity (TentativeReject(Offer(VulnerabilityReport))). - - Updates the offer status to TENTATIVELY_REJECTED and report status to INVALID. - - Args: - dispatchable: DispatchActivity containing the as_TentativeReject with Offer object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.data.status import ( - OfferStatus, - ReportStatus, - set_status, - ) - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.bt.report_management.states import RM - from vultron.enums import OfferStatusEnum - - activity = dispatchable.payload - - try: - # Rehydrate actor, offer, and report - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - - # Rehydrate the rejected offer (may be embedded or reference) - rejected_offer = rehydrate(activity.as_object) - - # Rehydrate the report that's the subject of the offer - subject_of_offer = rehydrate(rejected_offer.as_object) - - logger.info( - "Actor '%s' tentatively rejects offer '%s' of VulnerabilityReport '%s'", - actor_id, - rejected_offer.as_id, - subject_of_offer.as_id, - ) - - # Update offer status - offer_status = OfferStatus( - object_type=rejected_offer.as_type, - object_id=rejected_offer.as_id, - status=OfferStatusEnum.TENTATIVELY_REJECTED, - actor_id=actor_id, - ) - set_status(offer_status) - logger.info( - "Set offer '%s' status to TENTATIVELY_REJECTED", - rejected_offer.as_id, - ) - - # Update report status - report_status = ReportStatus( - object_type=subject_of_offer.as_type, - object_id=subject_of_offer.as_id, - status=RM.INVALID, - actor_id=actor_id, - ) - set_status(report_status) - logger.info( - "Set report '%s' status to INVALID", subject_of_offer.as_id - ) - - # Store the activity - dl = get_datalayer() - try: - dl.create(activity) - logger.info( - "Stored InvalidateReport activity with ID: %s", activity.as_id - ) - except ValueError as e: - logger.warning( - "InvalidateReport activity %s already exists: %s", - activity.as_id, - e, - ) - - except Exception as e: - logger.error( - "Error invalidating report in activity %s: %s", - activity.as_id, - str(e), - ) - - return None +def invalidate_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.invalidate_report(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ACK_REPORT) -def ack_report(dispatchable: DispatchActivity) -> None: - """ - Process an AckReport activity (Read(Offer(VulnerabilityReport))). - - Acknowledges receipt of a vulnerability report submission. - Stores the activity and logs the acknowledgement. - - Args: - dispatchable: DispatchActivity containing the as_Read with Offer object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - # Rehydrate actor and offer - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - - # Rehydrate the offer being acknowledged - offer = rehydrate(activity.as_object) - - # Rehydrate the report that's the subject of the offer - subject_of_offer = rehydrate(offer.as_object) - - logger.info( - "Actor '%s' acknowledges receipt of offer '%s' of VulnerabilityReport '%s'", - actor_id, - offer.as_id, - subject_of_offer.as_id, - ) - - # Store the activity - dl = get_datalayer() - try: - dl.create(activity) - logger.info( - "Stored AckReport activity with ID: %s", activity.as_id - ) - except ValueError as e: - logger.warning( - "AckReport activity %s already exists: %s", activity.as_id, e - ) - - except Exception as e: - logger.error( - "Error acknowledging report in activity %s: %s", - activity.as_id, - str(e), - ) - - return None +def ack_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.ack_report(dispatchable.payload, dl) @verify_semantics(MessageSemantics.CLOSE_REPORT) -def close_report(dispatchable: DispatchActivity) -> None: - """ - Process a CloseReport activity (Reject(Offer(VulnerabilityReport))). - - Updates the offer status to REJECTED and report status to CLOSED. - - Args: - dispatchable: DispatchActivity containing the as_Reject with Offer object - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.data.status import ( - OfferStatus, - ReportStatus, - set_status, - ) - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - from vultron.bt.report_management.states import RM - from vultron.enums import OfferStatusEnum - - activity = dispatchable.payload - - try: - # Rehydrate actor, offer, and report - actor = rehydrate(obj=activity.actor) - actor_id = actor.as_id - - # Rehydrate the rejected offer (may be embedded or reference) - rejected_offer = rehydrate(activity.as_object) - - # Rehydrate the report that's the subject of the offer - subject_of_offer = rehydrate(rejected_offer.as_object) - - logger.info( - "Actor '%s' rejects offer '%s' of VulnerabilityReport '%s'", - actor_id, - rejected_offer.as_id, - subject_of_offer.as_id, - ) - - # Update offer status - offer_status = OfferStatus( - object_type=rejected_offer.as_type, - object_id=rejected_offer.as_id, - status=OfferStatusEnum.REJECTED, - actor_id=actor_id, - ) - set_status(offer_status) - logger.info("Set offer '%s' status to REJECTED", rejected_offer.as_id) - - # Update report status - report_status = ReportStatus( - object_type=subject_of_offer.as_type, - object_id=subject_of_offer.as_id, - status=RM.CLOSED, - actor_id=actor_id, - ) - set_status(report_status) - logger.info("Set report '%s' status to CLOSED", subject_of_offer.as_id) - - # Store the activity - dl = get_datalayer() - try: - dl.create(activity) - logger.info( - "Stored CloseReport activity with ID: %s", activity.as_id - ) - except ValueError as e: - logger.warning( - "CloseReport activity %s already exists: %s", - activity.as_id, - e, - ) - - except Exception as e: - logger.error( - "Error closing report in activity %s: %s", - activity.as_id, - str(e), - ) - - return None +def close_report(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.close_report(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/status.py b/vultron/api/v2/backend/handlers/status.py index 2f9d90e4..b9a5c193 100644 --- a/vultron/api/v2/backend/handlers/status.py +++ b/vultron/api/v2/backend/handlers/status.py @@ -1,194 +1,37 @@ -""" -Handler functions for case and participant status activities. -""" +"""Handler functions for case and participant status activities — thin delegates to core use cases.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.status as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.CREATE_CASE_STATUS) -def create_case_status(dispatchable: DispatchActivity) -> None: - """ - Process a Create(CaseStatus) activity. - - Persists the CaseStatus to the DataLayer. Idempotent: if a CaseStatus - with the same ID already exists, the handler skips creation (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the Create(CaseStatus) - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - status = activity.as_object - - existing = dl.get(status.as_type.value, status.as_id) - if existing is not None: - logger.info( - "CaseStatus '%s' already stored — skipping (idempotent)", - status.as_id, - ) - return None - - dl.create(status) - logger.info("Stored CaseStatus '%s'", status.as_id) - - except Exception as e: - logger.error( - "Error in create_case_status for activity %s: %s", - activity.as_id, - str(e), - ) +def create_case_status(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.create_case_status(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ADD_CASE_STATUS_TO_CASE) -def add_case_status_to_case(dispatchable: DispatchActivity) -> None: - """ - Process an Add(CaseStatus, target=VulnerabilityCase) activity. - - Appends the CaseStatus to the case's case_statuses list and persists the - updated case. Idempotent: re-adding a status already in the list - succeeds without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the Add(CaseStatus, - target=VulnerabilityCase) - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - status = rehydrate(obj=activity.as_object) - case = rehydrate(obj=activity.target) - status_id = status.as_id if hasattr(status, "as_id") else str(status) - case_id = case.as_id - - existing_ids = [ - (s.as_id if hasattr(s, "as_id") else s) for s in case.case_statuses - ] - if status_id in existing_ids: - logger.info( - "CaseStatus '%s' already in case '%s' — skipping (idempotent)", - status_id, - case_id, - ) - return None - - case.case_statuses.append(status) - dl.update(case_id, object_to_record(case)) - logger.info("Added CaseStatus '%s' to case '%s'", status_id, case_id) - - except Exception as e: - logger.error( - "Error in add_case_status_to_case for activity %s: %s", - activity.as_id, - str(e), - ) +def add_case_status_to_case( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.add_case_status_to_case(dispatchable.payload, dl) @verify_semantics(MessageSemantics.CREATE_PARTICIPANT_STATUS) -def create_participant_status(dispatchable: DispatchActivity) -> None: - """ - Process a Create(ParticipantStatus) activity. - - Persists the ParticipantStatus to the DataLayer. Idempotent: if a - ParticipantStatus with the same ID already exists, the handler skips - creation (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the Create(ParticipantStatus) - """ - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - status = activity.as_object - - existing = dl.get(status.as_type.value, status.as_id) - if existing is not None: - logger.info( - "ParticipantStatus '%s' already stored — skipping (idempotent)", - status.as_id, - ) - return None - - dl.create(status) - logger.info("Stored ParticipantStatus '%s'", status.as_id) - - except Exception as e: - logger.error( - "Error in create_participant_status for activity %s: %s", - activity.as_id, - str(e), - ) +def create_participant_status( + dispatchable: DispatchEvent, dl: DataLayer +) -> None: + uc.create_participant_status(dispatchable.payload, dl) @verify_semantics(MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT) def add_participant_status_to_participant( - dispatchable: DispatchActivity, + dispatchable: DispatchEvent, dl: DataLayer ) -> None: - """ - Process an Add(ParticipantStatus, target=CaseParticipant) activity. - - Appends the ParticipantStatus to the participant's participant_statuses - list and persists the updated participant. Idempotent: re-adding a - status already in the list succeeds without side effects (ID-04-004). - - Args: - dispatchable: DispatchActivity containing the - Add(ParticipantStatus, target=CaseParticipant) - """ - from vultron.api.v2.data.rehydration import rehydrate - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - activity = dispatchable.payload - - try: - dl = get_datalayer() - status = rehydrate(obj=activity.as_object) - participant = rehydrate(obj=activity.target) - status_id = status.as_id if hasattr(status, "as_id") else str(status) - participant_id = participant.as_id - - existing_ids = [ - (s.as_id if hasattr(s, "as_id") else s) - for s in participant.participant_statuses - ] - if status_id in existing_ids: - logger.info( - "ParticipantStatus '%s' already on participant '%s' — " - "skipping (idempotent)", - status_id, - participant_id, - ) - return None - - participant.participant_statuses.append(status) - dl.update(participant_id, object_to_record(participant)) - logger.info( - "Added ParticipantStatus '%s' to participant '%s'", - status_id, - participant_id, - ) - - except Exception as e: - logger.error( - "Error in add_participant_status_to_participant for activity %s: %s", - activity.as_id, - str(e), - ) + uc.add_participant_status_to_participant(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/handlers/unknown.py b/vultron/api/v2/backend/handlers/unknown.py index 86fb8272..dae03d0e 100644 --- a/vultron/api/v2/backend/handlers/unknown.py +++ b/vultron/api/v2/backend/handlers/unknown.py @@ -1,17 +1,16 @@ -""" -Handler function for unknown/unrecognized activities. -""" +"""Handler function for unknown/unrecognized activities — thin delegate to core use case.""" import logging from vultron.api.v2.backend.handlers._base import verify_semantics -from vultron.enums import MessageSemantics -from vultron.types import DispatchActivity +from vultron.core.models.events import MessageSemantics +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +import vultron.core.use_cases.unknown as uc logger = logging.getLogger(__name__) @verify_semantics(MessageSemantics.UNKNOWN) -def unknown(dispatchable: DispatchActivity) -> None: - logger.warning("unknown handler called for dispatchable: %s", dispatchable) - return None +def unknown(dispatchable: DispatchEvent, dl: DataLayer) -> None: + uc.unknown(dispatchable.payload, dl) diff --git a/vultron/api/v2/backend/helpers.py b/vultron/api/v2/backend/helpers.py index b1c11020..5eb7827c 100644 --- a/vultron/api/v2/backend/helpers.py +++ b/vultron/api/v2/backend/helpers.py @@ -19,8 +19,8 @@ from fastapi import HTTPException from pydantic import BaseModel, ValidationError -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.registry import find_in_vocabulary +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.registry import find_in_vocabulary def obj_from_item(item: dict) -> as_Base: diff --git a/vultron/api/v2/backend/inbox_handler.py b/vultron/api/v2/backend/inbox_handler.py index 0c98421b..2ccf4247 100644 --- a/vultron/api/v2/backend/inbox_handler.py +++ b/vultron/api/v2/backend/inbox_handler.py @@ -12,57 +12,86 @@ # Created, in part, with funding and support from the United States Government # (see Acknowledgments file). This program may include and/or can make use of # certain third party source code, object code, documentation and other files -# (“Third Party Software”). See LICENSE.md for more details. +# ("Third Party Software"). See LICENSE.md for more details. # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University import logging from vultron.api.v2.backend import handlers # noqa: F401 +from vultron.api.v2.backend.handler_map import SEMANTICS_HANDLERS from vultron.api.v2.data.actor_io import get_actor_io from vultron.api.v2.data.rehydration import rehydrate -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.api.v2.errors import VultronApiValidationError -from vultron.as_vocab import VOCABULARY -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.behavior_dispatcher import get_dispatcher, prepare_for_dispatch -from vultron.types import DispatchActivity +from vultron.behavior_dispatcher import ( + ActivityDispatcher, + get_dispatcher, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.types import DispatchEvent +from vultron.wire.as2.extractor import extract_intent +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity logger = logging.getLogger(__name__) -DISPATCHER = get_dispatcher() -logger.info("Using dispatcher: %s", type(DISPATCHER).__name__) - -def raise_if_not_valid_activity(obj: as_Activity) -> None: +def prepare_for_dispatch(activity: as_Activity) -> DispatchEvent: + """ + Prepares an activity for dispatch by extracting its message semantics and packaging it into a DispatchEvent. """ - Raises a VultronApiValidationError if the given object is not a valid Activity. + logger.debug( + f"Preparing activity '{activity.as_id}' of type '{activity.as_type}' for dispatch." + ) + + event = extract_intent(activity) + + dispatch_msg = DispatchEvent( + semantic_type=event.semantic_type, + activity_id=activity.as_id, + payload=event, + ) + logger.debug( + f"Prepared dispatch message with semantics '{dispatch_msg.semantic_type}' for activity '{dispatch_msg.payload.activity_id}'" + ) + return dispatch_msg + + +_DISPATCHER: ActivityDispatcher | None = None + + +def init_dispatcher(dl: DataLayer) -> None: + """Initialise the module-level dispatcher with an injected DataLayer. + + Must be called once during application startup (e.g. from the FastAPI + lifespan event) before any inbox items are processed. Calling it more + than once (e.g. in tests) is allowed — the dispatcher is simply replaced. Args: - obj: The object to validate. - Returns: - None - Raises: - VultronApiValidationError: If the object is not a valid Activity. + dl: The DataLayer instance to inject into the dispatcher. """ - if obj.as_type not in VOCABULARY.activities: - raise VultronApiValidationError( - f"Invalid object type {obj.as_type} in inbox item, expected an Activity." - ) + global _DISPATCHER + _DISPATCHER = get_dispatcher(handler_map=SEMANTICS_HANDLERS, dl=dl) + logger.info("Initialised inbox dispatcher: %s", type(_DISPATCHER).__name__) -def dispatch(dispatchable: DispatchActivity) -> None: +def dispatch(dispatchable: DispatchEvent) -> None: """ - Dispatches the given activity using the global dispatcher. + Dispatches the given event using the module-level dispatcher. + Args: - dispatchable: The DispatchActivity to dispatch. - Returns: - None + dispatchable: The DispatchEvent to dispatch. + Raises: + RuntimeError: If the dispatcher has not been initialised via + :func:`init_dispatcher`. """ + if _DISPATCHER is None: + raise RuntimeError( + "Inbox dispatcher not initialised. " + "Call init_dispatcher() during application startup." + ) logger.debug( f"Dispatching activity '{dispatchable.activity_id}' with semantics '{dispatchable.semantic_type}'" ) - DISPATCHER.dispatch(dispatchable) + _DISPATCHER.dispatch(dispatchable) def handle_inbox_item(actor_id: str, obj: as_Activity) -> None: @@ -86,23 +115,20 @@ def handle_inbox_item(actor_id: str, obj: as_Activity) -> None: f"Validated object:\n{obj.model_dump_json(indent=2,exclude_none=True)}" ) - raise_if_not_valid_activity(obj) - dispatchable = prepare_for_dispatch(activity=obj) dispatch(dispatchable=dispatchable) -async def inbox_handler(actor_id: str) -> None: +async def inbox_handler(actor_id: str, dl: DataLayer) -> None: """ Process the inbox for the given actor. + Args: actor_id: The ID of the Actor whose inbox is being processed. + dl: The DataLayer instance to use for persistence operations. Returns: None - Raises: - None """ - dl = get_datalayer() actor = dl.read(actor_id) if actor is None: logger.warning(f"Actor {actor_id} not found in inbox_handler.") diff --git a/vultron/api/v2/backend/outbox_handler.py b/vultron/api/v2/backend/outbox_handler.py index 9e1cb0d6..8f584105 100644 --- a/vultron/api/v2/backend/outbox_handler.py +++ b/vultron/api/v2/backend/outbox_handler.py @@ -18,7 +18,7 @@ import logging -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer +from vultron.adapters.driven.datalayer_tinydb import get_datalayer logger = logging.getLogger(__name__) diff --git a/vultron/api/v2/backend/trigger_services/__init__.py b/vultron/api/v2/backend/trigger_services/__init__.py new file mode 100644 index 00000000..38f49696 --- /dev/null +++ b/vultron/api/v2/backend/trigger_services/__init__.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Trigger service layer for actor-initiated Vultron behaviors. + +Service functions accept domain parameters and a DataLayer instance injected +from the router layer. No HTTP concerns (routing, request parsing) belong +here; conversely, no DataLayer lookups belong in the router layer. +""" + +from vultron.api.v2.backend.trigger_services.case import ( + svc_defer_case, + svc_engage_case, +) +from vultron.api.v2.backend.trigger_services.embargo import ( + svc_evaluate_embargo, + svc_propose_embargo, + svc_terminate_embargo, +) +from vultron.api.v2.backend.trigger_services.report import ( + svc_close_report, + svc_invalidate_report, + svc_reject_report, + svc_validate_report, +) + +__all__ = [ + "svc_validate_report", + "svc_invalidate_report", + "svc_reject_report", + "svc_close_report", + "svc_engage_case", + "svc_defer_case", + "svc_propose_embargo", + "svc_evaluate_embargo", + "svc_terminate_embargo", +] diff --git a/vultron/api/v2/backend/trigger_services/_helpers.py b/vultron/api/v2/backend/trigger_services/_helpers.py new file mode 100644 index 00000000..330a3af8 --- /dev/null +++ b/vultron/api/v2/backend/trigger_services/_helpers.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Shared helper utilities for trigger service functions. + +These helpers are internal to the trigger_services package. They are not +part of the public service API and should not be imported from outside this +package. +""" + +import logging + +from fastapi import HTTPException, status + +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.db_record import object_to_record +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.bt.report_management.states import RM + +logger = logging.getLogger(__name__) + + +def not_found(resource_type: str, resource_id: str) -> HTTPException: + """Return a structured 404 per EH-05-001.""" + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "status": 404, + "error": "NotFound", + "message": f"{resource_type} '{resource_id}' not found.", + "activity_id": None, + }, + ) + + +def resolve_actor(actor_id: str, dl: DataLayer): + """Resolve actor by full ID or short ID; raise 404 if absent.""" + actor = dl.read(actor_id) + if actor is None: + actor = dl.find_actor_by_short_id(actor_id) + if actor is None: + raise not_found("Actor", actor_id) + return actor + + +def resolve_case(case_id: str, dl: DataLayer) -> VulnerabilityCase: + """Resolve a VulnerabilityCase by ID; raise 404/422 if absent or wrong type.""" + case_raw = dl.read(case_id) + if case_raw is None: + raise not_found("VulnerabilityCase", case_id) + if getattr(case_raw, "as_type", None) != "VulnerabilityCase": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "status": 422, + "error": "ValidationError", + "message": ( + f"Expected VulnerabilityCase, got " + f"{type(case_raw).__name__}." + ), + "activity_id": None, + }, + ) + return case_raw + + +def update_participant_rm_state( + case_id: str, actor_id: str, new_rm_state: RM, dl: DataLayer +) -> None: + """ + Append a new ParticipantStatus with new_rm_state to the actor's + CaseParticipant in the given case and persist the updated case. + + Logs a WARNING and returns without error if no participant record is found + (non-blocking for the outgoing trigger case; participant may be created + separately). + """ + case_obj = dl.read(case_id) + if ( + case_obj is None + or getattr(case_obj, "as_type", None) != "VulnerabilityCase" + ): + logger.warning( + "update_participant_rm_state: case '%s' not found or wrong type", + case_id, + ) + return + + for participant_ref in case_obj.case_participants: + if isinstance(participant_ref, str): + participant = dl.read(participant_ref) + if participant is None: + continue + else: + participant = participant_ref + + actor_ref = participant.attributed_to + p_actor_id = ( + actor_ref + if isinstance(actor_ref, str) + else getattr(actor_ref, "as_id", str(actor_ref)) + ) + if p_actor_id == actor_id: + if participant.participant_statuses: + latest = participant.participant_statuses[-1] + if latest.rm_state == new_rm_state: + logger.info( + "Participant '%s' already in RM state %s in case '%s' " + "(idempotent)", + actor_id, + new_rm_state, + case_id, + ) + return + new_status = ParticipantStatus( + actor=actor_id, + context=case_id, + rm_state=new_rm_state, + ) + participant.participant_statuses.append(new_status) + dl.update(participant.as_id, object_to_record(participant)) + logger.info( + "Set participant '%s' RM state to %s in case '%s'", + actor_id, + new_rm_state, + case_id, + ) + return + + logger.warning( + "update_participant_rm_state: no CaseParticipant for actor '%s' " + "in case '%s'; RM state not updated", + actor_id, + case_id, + ) + + +def outbox_ids(actor) -> set[str]: + """Return the set of string activity IDs in actor.outbox.items.""" + if not (hasattr(actor, "outbox") and actor.outbox and actor.outbox.items): + return set() + return {item for item in actor.outbox.items if isinstance(item, str)} + + +def add_activity_to_outbox( + actor_id: str, activity_id: str, dl: DataLayer +) -> None: + """Append an activity ID to an actor's outbox and persist the actor.""" + actor_obj = dl.read(actor_id) + if actor_obj is None: + logger.error("add_activity_to_outbox: actor '%s' not found", actor_id) + return + if not (hasattr(actor_obj, "outbox") and actor_obj.outbox is not None): + logger.error( + "add_activity_to_outbox: actor '%s' has no outbox", actor_id + ) + return + actor_obj.outbox.items.append(activity_id) + dl.update(actor_obj.as_id, object_to_record(actor_obj)) + logger.debug( + "Added activity '%s' to actor '%s' outbox", activity_id, actor_id + ) + + +def find_embargo_proposal(case_id: str, dl: DataLayer): + """ + Find the first stored EmProposeEmbargoActivity activity for the given case. + + Scans all Invite-typed objects in the DataLayer and returns the first + whose context matches case_id and whose object is an EmbargoEvent. + Returns None if no matching proposal is found. + """ + invite_records = dl.by_type("Invite") + for obj_id in invite_records: + obj = dl.read(obj_id) + if obj is None: + continue + context = obj.context + c_id = ( + context + if isinstance(context, str) + else getattr(context, "as_id", str(context)) + ) + if c_id != case_id: + continue + embargo_ref = getattr(obj, "as_object", None) + if embargo_ref is None: + continue + embargo_id = ( + embargo_ref + if isinstance(embargo_ref, str) + else getattr(embargo_ref, "as_id", None) + ) + if embargo_id is None: + continue + emb = dl.read(embargo_id) + if emb is not None and str(getattr(emb, "as_type", "")) == "Event": + return obj + return None diff --git a/vultron/api/v2/backend/trigger_services/_models.py b/vultron/api/v2/backend/trigger_services/_models.py new file mode 100644 index 00000000..b9ae0673 --- /dev/null +++ b/vultron/api/v2/backend/trigger_services/_models.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Pydantic request models for trigger endpoints. + +CS-09-002: ValidateReportRequest, InvalidateReportRequest, and +CloseReportRequest share a common base (ReportTriggerRequest) because they +have identical fields. RejectReportRequest also uses offer_id but requires +a non-optional note field. +""" + +import logging +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator + +logger = logging.getLogger(__name__) + + +class ReportTriggerRequest(BaseModel): + """ + Shared base for report-level trigger requests. + + TB-03-001: Must include offer_id to identify the target offer. + TB-03-002: Unknown fields are silently ignored (extra="ignore"). + TB-03-003: Optional note field may be included. + """ + + model_config = ConfigDict(extra="ignore") + + offer_id: str + note: str | None = None + + +class ValidateReportRequest(ReportTriggerRequest): + """Request body for the validate-report trigger endpoint.""" + + +class InvalidateReportRequest(ReportTriggerRequest): + """Request body for the invalidate-report trigger endpoint.""" + + +class CloseReportRequest(ReportTriggerRequest): + """ + Request body for the close-report trigger endpoint. + + Distinction from reject-report: close-report closes a report after the + RM lifecycle has proceeded (RM → C transition; emits RC message), while + reject-report hard-rejects an incoming offer before validation completes. + """ + + +class RejectReportRequest(BaseModel): + """ + Request body for the reject-report trigger endpoint. + + TB-03-001: Must include offer_id to identify the target offer. + TB-03-002: Unknown fields are silently ignored (extra="ignore"). + TB-03-004: note is required (hard-close decisions warrant documented + justification); an empty note emits a WARNING. + """ + + model_config = ConfigDict(extra="ignore") + + offer_id: str + note: str + + @field_validator("note") + @classmethod + def note_must_be_present(cls, v: str) -> str: + if not v.strip(): + logger.warning( + "reject-report trigger received an empty note field; " + "hard-close decisions should include a documented reason." + ) + return v + + +class CaseTriggerRequest(BaseModel): + """ + Request body for case-level trigger endpoints. + + TB-03-001: Must include case_id to identify the target case. + TB-03-002: Unknown fields are silently ignored (extra="ignore"). + """ + + model_config = ConfigDict(extra="ignore") + + case_id: str + + +class ProposeEmbargoRequest(BaseModel): + """ + Request body for the propose-embargo trigger endpoint. + + TB-03-001: Must include case_id to identify the target case. + TB-03-002: Unknown fields are silently ignored (extra="ignore"). + TB-03-003: Optional note field may be included. + """ + + model_config = ConfigDict(extra="ignore") + + case_id: str + note: str | None = None + end_time: datetime | None = None + + +class EvaluateEmbargoRequest(BaseModel): + """ + Request body for the evaluate-embargo trigger endpoint. + + TB-03-001: Must include case_id to identify the target case. + TB-03-002: Unknown fields are silently ignored (extra="ignore"). + Optional proposal_id identifies the specific EmProposeEmbargoActivity to accept; + if omitted, the first pending proposal for the case is used. + """ + + model_config = ConfigDict(extra="ignore") + + case_id: str + proposal_id: str | None = None + + +class TerminateEmbargoRequest(BaseModel): + """ + Request body for the terminate-embargo trigger endpoint. + + TB-03-001: Must include case_id to identify the target case. + TB-03-002: Unknown fields are silently ignored (extra="ignore"). + """ + + model_config = ConfigDict(extra="ignore") + + case_id: str diff --git a/vultron/api/v2/backend/trigger_services/case.py b/vultron/api/v2/backend/trigger_services/case.py new file mode 100644 index 00000000..9d3bc927 --- /dev/null +++ b/vultron/api/v2/backend/trigger_services/case.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Domain service functions for case-level trigger behaviors. + +Each function accepts domain parameters and a DataLayer instance (injected +from the router via Depends). No HTTP routing or request parsing belongs +here. +""" + +import logging + +from vultron.api.v2.backend.trigger_services._helpers import ( + add_activity_to_outbox, + resolve_actor, + resolve_case, + update_participant_rm_state, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.wire.as2.vocab.activities.case import ( + RmDeferCaseActivity, + RmEngageCaseActivity, +) +from vultron.bt.report_management.states import RM + +logger = logging.getLogger(__name__) + + +def svc_engage_case(actor_id: str, case_id: str, dl: DataLayer) -> dict: + """ + Engage a case (RM → ACCEPTED). + + Emits RmEngageCaseActivity (Join(VulnerabilityCase)), updates the actor's own + CaseParticipant RM state, adds to actor outbox, and returns + {"activity": {...}}. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, + TB-03-002, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + case = resolve_case(case_id, dl) + + engage_activity = RmEngageCaseActivity( + actor=actor_id, + object=case.as_id, + ) + + try: + dl.create(engage_activity) + except ValueError: + logger.warning( + "EngageCase activity '%s' already exists", engage_activity.as_id + ) + + update_participant_rm_state(case.as_id, actor_id, RM.ACCEPTED, dl) + + add_activity_to_outbox(actor_id, engage_activity.as_id, dl) + + logger.info( + "Actor '%s' engaged case '%s' (RM → ACCEPTED)", + actor_id, + case.as_id, + ) + + activity = engage_activity.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} + + +def svc_defer_case(actor_id: str, case_id: str, dl: DataLayer) -> dict: + """ + Defer a case (RM → DEFERRED). + + Emits RmDeferCaseActivity (Ignore(VulnerabilityCase)), updates the actor's own + CaseParticipant RM state, adds to actor outbox, and returns + {"activity": {...}}. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, + TB-03-002, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + case = resolve_case(case_id, dl) + + defer_activity = RmDeferCaseActivity( + actor=actor_id, + object=case.as_id, + ) + + try: + dl.create(defer_activity) + except ValueError: + logger.warning( + "DeferCase activity '%s' already exists", defer_activity.as_id + ) + + update_participant_rm_state(case.as_id, actor_id, RM.DEFERRED, dl) + + add_activity_to_outbox(actor_id, defer_activity.as_id, dl) + + logger.info( + "Actor '%s' deferred case '%s' (RM → DEFERRED)", + actor_id, + case.as_id, + ) + + activity = defer_activity.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} diff --git a/vultron/api/v2/backend/trigger_services/embargo.py b/vultron/api/v2/backend/trigger_services/embargo.py new file mode 100644 index 00000000..6b7285d3 --- /dev/null +++ b/vultron/api/v2/backend/trigger_services/embargo.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Domain service functions for embargo-level trigger behaviors. + +Each function accepts domain parameters and a DataLayer instance (injected +from the router via Depends). No HTTP routing or request parsing belongs +here. +""" + +import logging +from datetime import datetime + +from fastapi import HTTPException, status + +from vultron.api.v2.backend.trigger_services._helpers import ( + add_activity_to_outbox, + find_embargo_proposal, + not_found, + resolve_actor, + resolve_case, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.db_record import object_to_record +from vultron.wire.as2.vocab.activities.embargo import ( + AnnounceEmbargoActivity, + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, +) +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.bt.embargo_management.states import EM + +logger = logging.getLogger(__name__) + + +def svc_propose_embargo( + actor_id: str, + case_id: str, + note: str | None, + end_time: datetime | None, + dl: DataLayer, +) -> dict: + """ + Propose an embargo on a case. + + Creates a new EmbargoEvent and emits EmProposeEmbargoActivity + (Invite(EmbargoEvent)). EM state transitions: + - EM.N → EM.P (new proposal; emits EP) + - EM.A → EM.R (revision proposal; emits EV) + - EM.P or EM.R: counter-proposal; no state change + + Returns HTTP 409 if EM state is EXITED. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, + TB-03-002, TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + case = resolve_case(case_id, dl) + + em_state = case.current_status.em_state + + if em_state == EM.EXITED: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "status": 409, + "error": "Conflict", + "message": ( + f"Cannot propose embargo: case '{case.as_id}' EM state " + f"is EXITED." + ), + "activity_id": None, + }, + ) + + embargo_kwargs: dict = {"context": case.as_id} + if end_time is not None: + embargo_kwargs["end_time"] = end_time + + embargo = EmbargoEvent(**embargo_kwargs) + + try: + dl.create(embargo) + except ValueError: + logger.warning("EmbargoEvent '%s' already exists", embargo.as_id) + + proposal = EmProposeEmbargoActivity( + actor=actor_id, + object=embargo.as_id, + context=case.as_id, + ) + + try: + dl.create(proposal) + except ValueError: + logger.warning( + "EmProposeEmbargoActivity '%s' already exists", proposal.as_id + ) + + if em_state == EM.NO_EMBARGO: + case.current_status.em_state = EM.PROPOSED + logger.info( + "Actor '%s' proposed embargo '%s' on case '%s' (EM N → P)", + actor_id, + embargo.as_id, + case.as_id, + ) + elif em_state == EM.ACTIVE: + case.current_status.em_state = EM.REVISE + logger.info( + "Actor '%s' proposed embargo revision '%s' on case '%s' (EM A → R)", + actor_id, + embargo.as_id, + case.as_id, + ) + else: + logger.info( + "Actor '%s' counter-proposed embargo '%s' on case '%s' (EM %s, no state change)", + actor_id, + embargo.as_id, + case.as_id, + em_state, + ) + + case.proposed_embargoes.append(embargo.as_id) + dl.update(case.as_id, object_to_record(case)) + + add_activity_to_outbox(actor_id, proposal.as_id, dl) + + activity = proposal.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} + + +def svc_evaluate_embargo( + actor_id: str, + case_id: str, + proposal_id: str | None, + dl: DataLayer, +) -> dict: + """ + Accept an embargo proposal (evaluate-embargo). + + Emits EmAcceptEmbargoActivity (Accept(EmProposeEmbargoActivity)), activates the embargo + on the case (EM → ACTIVE), and adds to actor outbox. + + If proposal_id is None, the first pending EmProposeEmbargoActivity for the case + is used. Returns 404 if no proposal is found. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, + TB-03-002, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + case = resolve_case(case_id, dl) + + if proposal_id: + proposal_raw = dl.read(proposal_id) + if proposal_raw is None: + raise not_found("EmbargoProposal", proposal_id) + proposal = proposal_raw + else: + proposal = find_embargo_proposal(case.as_id, dl) + if proposal is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "status": 404, + "error": "NotFound", + "message": ( + f"No pending embargo proposal found for case " + f"'{case.as_id}'." + ), + "activity_id": None, + }, + ) + + if str(getattr(proposal, "as_type", "")) != "Invite": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "status": 422, + "error": "ValidationError", + "message": ( + f"Expected an Invite (embargo proposal), got " + f"{type(proposal).__name__}." + ), + "activity_id": None, + }, + ) + + embargo_ref = getattr(proposal, "as_object", None) + embargo_id = ( + embargo_ref + if isinstance(embargo_ref, str) + else getattr(embargo_ref, "as_id", None) + ) + if not embargo_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "status": 422, + "error": "ValidationError", + "message": "Proposal is missing an embargo event reference.", + "activity_id": None, + }, + ) + embargo = dl.read(embargo_id) + if embargo is None or str(getattr(embargo, "as_type", "")) != "Event": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "status": 422, + "error": "ValidationError", + "message": f"Could not resolve EmbargoEvent '{embargo_id}'.", + "activity_id": None, + }, + ) + + accept = EmAcceptEmbargoActivity( + actor=actor_id, + object=proposal.as_id, + context=case.as_id, + ) + + try: + dl.create(accept) + except ValueError: + logger.warning( + "EmAcceptEmbargoActivity '%s' already exists", accept.as_id + ) + + case.set_embargo(embargo_id) + dl.update(case.as_id, object_to_record(case)) + + add_activity_to_outbox(actor_id, accept.as_id, dl) + + logger.info( + "Actor '%s' accepted embargo proposal '%s'; activated embargo '%s' " + "on case '%s' (EM → ACTIVE)", + actor_id, + proposal.as_id, + embargo_id, + case.as_id, + ) + + activity = accept.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} + + +def svc_terminate_embargo(actor_id: str, case_id: str, dl: DataLayer) -> dict: + """ + Terminate the active embargo on a case. + + Emits AnnounceEmbargoActivity (ET message), sets case EM state to EXITED, clears + the active embargo, and adds to actor outbox. Returns 409 if the case + has no active embargo. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, + TB-03-002, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + case = resolve_case(case_id, dl) + + if case.active_embargo is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "status": 409, + "error": "Conflict", + "message": ( + f"Case '{case.as_id}' has no active embargo to terminate." + ), + "activity_id": None, + }, + ) + + embargo_id = ( + case.active_embargo + if isinstance(case.active_embargo, str) + else case.active_embargo.as_id + ) + + announce = AnnounceEmbargoActivity( + actor=actor_id, + object=embargo_id, + context=case.as_id, + ) + + try: + dl.create(announce) + except ValueError: + logger.warning( + "AnnounceEmbargoActivity '%s' already exists", announce.as_id + ) + + case.current_status.em_state = EM.EXITED + case.active_embargo = None + dl.update(case.as_id, object_to_record(case)) + + add_activity_to_outbox(actor_id, announce.as_id, dl) + + logger.info( + "Actor '%s' terminated embargo '%s' on case '%s' (EM → EXITED)", + actor_id, + embargo_id, + case.as_id, + ) + + activity = announce.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} diff --git a/vultron/api/v2/backend/trigger_services/report.py b/vultron/api/v2/backend/trigger_services/report.py new file mode 100644 index 00000000..1a8bbd6c --- /dev/null +++ b/vultron/api/v2/backend/trigger_services/report.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Domain service functions for report-level trigger behaviors. + +Each function accepts domain parameters and a DataLayer instance (injected +from the router via Depends). No HTTP routing or request parsing belongs +here. +""" + +import logging + +from fastapi import HTTPException, status + +from vultron.api.v2.backend.trigger_services._helpers import ( + add_activity_to_outbox, + not_found, + outbox_ids, + resolve_actor, +) +from vultron.api.v2.data.rehydration import rehydrate +from vultron.api.v2.data.status import ( + OfferStatus, + ReportStatus, + get_status_layer, + set_status, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.wire.as2.vocab.activities.report import ( + RmCloseReportActivity, + RmInvalidateReportActivity, +) +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.report.validate_tree import ( + create_validate_report_tree, +) +from vultron.bt.report_management.states import RM +from vultron.core.models.status import OfferStatusEnum + +logger = logging.getLogger(__name__) + + +def _resolve_offer_and_report(offer_id: str, dl: DataLayer): + """Resolve offer and its embedded report; raise 404/422 on failure.""" + offer_raw = dl.read(offer_id) + if offer_raw is None: + raise not_found("Offer", offer_id) + + try: + offer = rehydrate(offer_raw) + report = rehydrate(offer.as_object) + except (ValueError, KeyError, AttributeError) as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "status": 422, + "error": "ValidationError", + "message": str(e), + "activity_id": None, + }, + ) + + if getattr(report, "as_type", None) != "VulnerabilityReport": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "status": 422, + "error": "ValidationError", + "message": ( + f"Expected VulnerabilityReport, got " + f"{getattr(report, 'as_type', type(report).__name__)}." + ), + "activity_id": None, + }, + ) + + return offer, report + + +def svc_validate_report( + actor_id: str, offer_id: str, note: str | None, dl: DataLayer +) -> dict: + """ + Validate a report offer using the ValidateReportBT behavior tree. + + Resolves the actor and offer, invokes the ValidateReportBT tree via the + bridge layer, and returns {"activity": {...}} with the resulting activity. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-03-001, TB-03-002, + TB-03-003, TB-04-001, TB-05-001, TB-05-002, TB-06-001, TB-06-002, + TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + offer, report = _resolve_offer_and_report(offer_id, dl) + report_id = report.as_id + offer_id = offer.as_id + + before = outbox_ids(actor) + + bridge = BTBridge(datalayer=dl) + tree = create_validate_report_tree(report_id=report_id, offer_id=offer_id) + + context = {} + if note: + context["note"] = note + + bridge.execute_with_setup(tree, actor_id=actor_id, **context) + + activity = None + actor_after = dl.read(actor_id) + if actor_after is not None: + after = outbox_ids(actor_after) + new_items = after - before + if new_items: + activity_id = next(iter(new_items)) + activity_obj = dl.read(activity_id) + if activity_obj is not None: + activity = activity_obj.model_dump( + by_alias=True, exclude_none=True + ) + + return {"activity": activity} + + +def svc_invalidate_report( + actor_id: str, offer_id: str, note: str | None, dl: DataLayer +) -> dict: + """ + Emit RmInvalidateReportActivity (TentativeReject) for the given offer. + + Updates offer status to TENTATIVELY_REJECTED and report status to INVALID. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, + TB-03-002, TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + offer, report = _resolve_offer_and_report(offer_id, dl) + + invalidate_activity = RmInvalidateReportActivity( + actor=actor_id, + object=offer.as_id, + ) + + try: + dl.create(invalidate_activity) + except ValueError: + logger.warning( + "InvalidateReport activity '%s' already exists", + invalidate_activity.as_id, + ) + + set_status( + OfferStatus( + object_type=offer.as_type, + object_id=offer.as_id, + status=OfferStatusEnum.TENTATIVELY_REJECTED, + actor_id=actor_id, + ) + ) + set_status( + ReportStatus( + object_type=report.as_type, + object_id=report.as_id, + status=RM.INVALID, + actor_id=actor_id, + ) + ) + + add_activity_to_outbox(actor_id, invalidate_activity.as_id, dl) + + logger.info( + "Actor '%s' invalidated offer '%s' (report '%s')", + actor_id, + offer.as_id, + report.as_id, + ) + + activity = invalidate_activity.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} + + +def svc_reject_report( + actor_id: str, offer_id: str, note: str, dl: DataLayer +) -> dict: + """ + Hard-close a report offer by emitting RmCloseReportActivity (Reject). + + Updates offer status to REJECTED and report status to CLOSED. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, + TB-03-002, TB-03-004, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + offer, report = _resolve_offer_and_report(offer_id, dl) + + reject_activity = RmCloseReportActivity( + actor=actor_id, + object=offer.as_id, + ) + + try: + dl.create(reject_activity) + except ValueError: + logger.warning( + "CloseReport activity '%s' already exists", reject_activity.as_id + ) + + set_status( + OfferStatus( + object_type=offer.as_type, + object_id=offer.as_id, + status=OfferStatusEnum.REJECTED, + actor_id=actor_id, + ) + ) + set_status( + ReportStatus( + object_type=report.as_type, + object_id=report.as_id, + status=RM.CLOSED, + actor_id=actor_id, + ) + ) + + add_activity_to_outbox(actor_id, reject_activity.as_id, dl) + + logger.info( + "Actor '%s' hard-closed offer '%s' (report '%s'); note: %s", + actor_id, + offer.as_id, + report.as_id, + note, + ) + + activity = reject_activity.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} + + +def svc_close_report( + actor_id: str, offer_id: str, note: str | None, dl: DataLayer +) -> dict: + """ + Close a report via the RM lifecycle (RM → C transition). + + Emits RmCloseReportActivity (Reject). Returns 409 if report is already CLOSED. + Updates offer status to REJECTED and report status to CLOSED. + + Distinction from reject_report: close_report closes a report that has + already progressed through the RM lifecycle (RM → C), while reject_report + hard-rejects an incoming offer before validation completes. + + Implements: TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, + TB-03-002, TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + actor = resolve_actor(actor_id, dl) + actor_id = actor.as_id + + offer, report = _resolve_offer_and_report(offer_id, dl) + + status_layer = get_status_layer() + type_dict = status_layer.get(report.as_type, {}) + id_dict = type_dict.get(report.as_id, {}) + actor_status_dict = id_dict.get(actor_id, {}) + current_rm_state = actor_status_dict.get("status") + + if current_rm_state == RM.CLOSED: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "status": 409, + "error": "Conflict", + "message": (f"Report '{report.as_id}' is already CLOSED."), + "activity_id": None, + }, + ) + + close_activity = RmCloseReportActivity( + actor=actor_id, + object=offer.as_id, + ) + + try: + dl.create(close_activity) + except ValueError: + logger.warning( + "CloseReport activity '%s' already exists", close_activity.as_id + ) + + set_status( + OfferStatus( + object_type=offer.as_type, + object_id=offer.as_id, + status=OfferStatusEnum.REJECTED, + actor_id=actor_id, + ) + ) + set_status( + ReportStatus( + object_type=report.as_type, + object_id=report.as_id, + status=RM.CLOSED, + actor_id=actor_id, + ) + ) + + add_activity_to_outbox(actor_id, close_activity.as_id, dl) + + logger.info( + "Actor '%s' closed offer '%s' (report '%s') via RM lifecycle; note: %s", + actor_id, + offer.as_id, + report.as_id, + note, + ) + + activity = close_activity.model_dump(by_alias=True, exclude_none=True) + return {"activity": activity} diff --git a/vultron/api/v2/data/rehydration.py b/vultron/api/v2/data/rehydration.py index 886e6e21..583e5f92 100644 --- a/vultron/api/v2/data/rehydration.py +++ b/vultron/api/v2/data/rehydration.py @@ -21,9 +21,9 @@ from pydantic import ValidationError -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.base.registry import find_in_vocabulary +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.registry import find_in_vocabulary logger = logging.getLogger(__name__) diff --git a/vultron/api/v2/data/status.py b/vultron/api/v2/data/status.py index 09c959f1..be0a16fb 100644 --- a/vultron/api/v2/data/status.py +++ b/vultron/api/v2/data/status.py @@ -1,9 +1,13 @@ #!/usr/bin/env python -""" -Provides TODO writeme +"""Backward-compatible re-export of status-tracking models. + +The authoritative definitions live in ``vultron.core.models.status``. +New code should import from there directly. This shim exists for +callers in the adapter layer (api/v2/backend/) that predate the +core/adapter split. """ -# Copyright (c) 2025 Carnegie Mellon University and Contributors. +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. # - see Contributors.md for a full list of Contributors # - see ContributionInstructions.md for information on how you can Contribute to this project # Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is @@ -12,110 +16,16 @@ # Created, in part, with funding and support from the United States Government # (see Acknowledgments file). This program may include and/or can make use of # certain third party source code, object code, documentation and other files -# (“Third Party Software”). See LICENSE.md for more details. +# ("Third Party Software"). See LICENSE.md for more details. # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from pydantic import BaseModel, Field - -from vultron.enums import OfferStatusEnum -from vultron.bt.report_management.states import RM - -STATUS: dict[str, dict] = dict() - - -class ObjectStatus(BaseModel): - """ - Represents a status of an object in the Vultron Demo. - """ - - object_type: str = Field( - description="The type of the object whose status is being represented. Taken from the as_type field of the object.", - ) - object_id: str = Field( - description="The ID of the object whose status is being represented. Taken from the as_id field of the object." - ) - actor_id: str | None = Field( - default=None, - description="The actor to whom this status is relevant, if applicable.", - ) - status: str # replace with a StrEnum in subclasses - - -class OfferStatus(ObjectStatus): - """ - Represents the status of an Offer object. - """ - - status: OfferStatusEnum = Field( - default=OfferStatusEnum.RECEIVED, - description=f"The status of the Offer. Possible values are: {', '.join([status.name for status in OfferStatusEnum])}.", - ) - - -class ReportStatus(ObjectStatus): - """ - Represents the status of a VulnerabilityReport object. - """ - - status: RM = Field( - default=RM.RECEIVED, - description=f"The status of the VulnerabilityReport. Possible values are: {', '.join([status.name for status in RM])}.", - ) - - -def status_to_record_dict(status_record: ObjectStatus) -> dict: - """ - Converts an ObjectStatus instance to a dictionary suitable for storage. - Args: - status_record: the ObjectStatus instance to convert. - - Returns: - dict: A dictionary representation of the ObjectStatus. - """ - d = { - status_record.object_type: { - status_record.object_id: { - status_record.actor_id: status_record.model_dump() - } - } - } - return d - - -def set_status(status_record: ObjectStatus) -> None: - """ - Sets the status of an object in the Vultron Demo. - - Args: - status_record: An ObjectStatus instance representing the status to set. - """ - sl = get_status_layer() - sl.update(status_to_record_dict(status_record)) - - -def get_status_layer() -> dict[str, dict]: - """ - Gets the status layer from the data store. - - Returns: - dict: The status layer. - """ - return STATUS - - -def main(): - print( - OfferStatus(object_id="foo", object_type="Foo").model_dump_json( - indent=2 - ) - ) - print( - ReportStatus(object_id="bar", object_type="Bar").model_dump_json( - indent=2 - ) - ) - - -if __name__ == "__main__": - main() +from vultron.core.models.status import ( # noqa: F401 + ObjectStatus, + OfferStatus, + ReportStatus, + STATUS, + get_status_layer, + set_status, + status_to_record_dict, +) diff --git a/vultron/api/v2/data/utils.py b/vultron/api/v2/data/utils.py index f49da520..c05b8839 100644 --- a/vultron/api/v2/data/utils.py +++ b/vultron/api/v2/data/utils.py @@ -17,10 +17,18 @@ Provides TODO writeme """ +import os +import re from urllib.parse import urljoin, urlparse from uuid import uuid4 -BASE_URL = "https://demo.vultron.local/" +BASE_URL = os.environ.get("VULTRON_BASE_URL", "https://demo.vultron.local/") + +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) +_URN_UUID_PREFIX = "urn:uuid:" def id_prefix(object_type: str) -> str: @@ -39,13 +47,22 @@ def make_id(object_type: str) -> str: def parse_id(object_id: str) -> dict[str, str]: - """Parses an object ID into its prefix, type, and UUID components.""" - - # if the object_id is a url, split it into parts: - # after last slash is the object id - # before that is the object type - # and everything in front of that is the base url - + """Parses an object ID into its prefix, type, and UUID components. + + Handles both HTTPS-URL form (``https://example.org/Type/uuid``) and + ``urn:uuid:`` form (``urn:uuid:uuid``). For ``urn:uuid:`` IDs the + returned ``object_type`` is ``None`` and ``object_id`` is the bare UUID + portion. + """ + if object_id.startswith(_URN_UUID_PREFIX): + bare_uuid = object_id[len(_URN_UUID_PREFIX) :] + return { + "base_url": _URN_UUID_PREFIX, + "object_type": None, + "object_id": bare_uuid, + } + + # HTTPS or other URL form parsed_url = urlparse(object_id) path_parts = parsed_url.path.lstrip("/").split("/") diff --git a/vultron/api/v2/routers/actors.py b/vultron/api/v2/routers/actors.py index ae144e7a..bc1edf8b 100644 --- a/vultron/api/v2/routers/actors.py +++ b/vultron/api/v2/routers/actors.py @@ -25,15 +25,21 @@ ) from vultron.api.v2.backend.outbox_handler import outbox_handler from vultron.api.v2.data.actor_io import get_actor_io -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.as_vocab import VOCABULARY -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.collections import as_OrderedCollection -from vultron.as_vocab.base.registry import find_in_vocabulary -from vultron.as_vocab.type_helpers import AsActivityType +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.db_record import object_to_record +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.collections import ( + as_OrderedCollection, +) +from vultron.wire.as2.vocab.base.registry import find_in_vocabulary +from vultron.wire.as2.vocab.type_helpers import AsActivityType +from vultron.wire.as2.errors import ( + VultronParseError, + VultronParseMissingTypeError, +) +from vultron.wire.as2.parser import parse_activity as _parse_activity logger = logging.getLogger("uvicorn.error") @@ -136,35 +142,33 @@ def get_actor_inbox( def parse_activity(body: dict) -> AsActivityType: - """Parses the incoming request body into an as_Activity object. + """HTTP adapter: parse request body and map wire errors to HTTP responses. + + Delegates AS2 parsing to the wire layer and converts domain parse errors + into appropriate HTTP status codes for FastAPI. + Args: body: The request body as a dictionary. + Returns: - An as_Activity object. + A typed as_Activity subclass instance. + Raises: - HTTPException: If the activity type is unknown or validation fails. + HTTPException: 400 if the `type` field is missing; 422 for all other + parse failures (unknown type, validation error). """ logger.info(f"Parsing activity from request body. {body}") - - as_type = body.get("type") - if as_type is None: + try: + return _parse_activity(body) + except VultronParseMissingTypeError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing 'type' field in activity.", + detail=str(exc), ) - - cls = VOCABULARY.activities.get(as_type) - if cls is None: + except VultronParseError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Unrecognized activity type.", - ) - - try: - return cls.model_validate(body) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e) + detail=str(exc), ) @@ -232,7 +236,7 @@ def post_actor_inbox( logger.error(f"Actor {actor.as_id} has no inbox - cannot add activity") # Trigger inbox processing (in the background) using the full_actor_id - background_tasks.add_task(inbox_handler, full_actor_id) + background_tasks.add_task(inbox_handler, full_actor_id, dl) return None diff --git a/vultron/api/v2/routers/datalayer.py b/vultron/api/v2/routers/datalayer.py index c6e221e1..a42be2b3 100644 --- a/vultron/api/v2/routers/datalayer.py +++ b/vultron/api/v2/routers/datalayer.py @@ -21,13 +21,17 @@ from fastapi import APIRouter, Depends, status, HTTPException from vultron.api.v2.data.rehydration import rehydrate -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.collections import as_OrderedCollection -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.datalayer_tinydb import get_datalayer +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Offer +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.collections import ( + as_OrderedCollection, +) +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) router = APIRouter(prefix="/datalayer", tags=["datalayer"]) @@ -200,7 +204,7 @@ def reset_datalayer( """Resets the datalayer by clearing all stored objects.""" datalayer.clear_all() if init: - from vultron.as_vocab.examples._base import initialize_examples + from vultron.wire.as2.vocab.examples._base import initialize_examples initialize_examples(datalayer=datalayer) diff --git a/vultron/api/v2/routers/examples.py b/vultron/api/v2/routers/examples.py index eb9bb23b..00941618 100644 --- a/vultron/api/v2/routers/examples.py +++ b/vultron/api/v2/routers/examples.py @@ -19,9 +19,9 @@ from fastapi import APIRouter -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.object_types import as_Note -from vultron.as_vocab.examples import vocab_examples +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.object_types import as_Note +from vultron.wire.as2.vocab.examples import vocab_examples router = APIRouter( prefix="/examples", diff --git a/vultron/api/v2/routers/trigger_case.py b/vultron/api/v2/routers/trigger_case.py new file mode 100644 index 00000000..54f22959 --- /dev/null +++ b/vultron/api/v2/routers/trigger_case.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Trigger router for case-management behaviors. + +Thin wrapper: validates request → calls service → returns response. +All domain logic lives in vultron.api.v2.backend.trigger_services.case. +""" + +from fastapi import APIRouter, Depends, status + +from vultron.api.v2.backend.trigger_services._models import CaseTriggerRequest +from vultron.api.v2.backend.trigger_services.case import ( + svc_defer_case, + svc_engage_case, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.datalayer_tinydb import get_datalayer + +router = APIRouter(prefix="/actors", tags=["Triggers"]) + + +@router.post( + "/{actor_id}/trigger/engage-case", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger case engagement.", + description=( + "Triggers the engage-case behavior for the given actor. " + "Emits a Join(VulnerabilityCase) activity (RmEngageCaseActivity), " + "transitions the actor's RM state to ACCEPTED in the case, " + "and returns the activity in the response body (TB-04-001)." + ), +) +def trigger_engage_case( + actor_id: str, + body: CaseTriggerRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the engage-case behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, + TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_engage_case(actor_id, body.case_id, dl) + + +@router.post( + "/{actor_id}/trigger/defer-case", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger case deferral.", + description=( + "Triggers the defer-case behavior for the given actor. " + "Emits an Ignore(VulnerabilityCase) activity (RmDeferCaseActivity), " + "transitions the actor's RM state to DEFERRED in the case, " + "and returns the activity in the response body (TB-04-001)." + ), +) +def trigger_defer_case( + actor_id: str, + body: CaseTriggerRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the defer-case behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, + TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_defer_case(actor_id, body.case_id, dl) diff --git a/vultron/api/v2/routers/trigger_embargo.py b/vultron/api/v2/routers/trigger_embargo.py new file mode 100644 index 00000000..109ae760 --- /dev/null +++ b/vultron/api/v2/routers/trigger_embargo.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Trigger router for embargo-management behaviors. + +Thin wrapper: validates request → calls service → returns response. +All domain logic lives in vultron.api.v2.backend.trigger_services.embargo. +""" + +from fastapi import APIRouter, Depends, status + +from vultron.api.v2.backend.trigger_services._models import ( + EvaluateEmbargoRequest, + ProposeEmbargoRequest, + TerminateEmbargoRequest, +) +from vultron.api.v2.backend.trigger_services.embargo import ( + svc_evaluate_embargo, + svc_propose_embargo, + svc_terminate_embargo, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.datalayer_tinydb import get_datalayer + +router = APIRouter(prefix="/actors", tags=["Triggers"]) + + +@router.post( + "/{actor_id}/trigger/propose-embargo", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger an embargo proposal.", + description=( + "Triggers the propose-embargo behavior for the given actor. " + "Creates a new EmbargoEvent and emits an EmProposeEmbargoActivity " + "(Invite(EmbargoEvent)) activity. " + "EM state transitions: N → P (new proposal) or A → R (revision). " + "Returns the resulting activity in the response body (TB-04-001)." + ), +) +def trigger_propose_embargo( + actor_id: str, + body: ProposeEmbargoRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the propose-embargo behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, TB-03-002, + TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_propose_embargo( + actor_id, body.case_id, body.note, body.end_time, dl + ) + + +@router.post( + "/{actor_id}/trigger/evaluate-embargo", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger embargo evaluation (accept a proposal).", + description=( + "Triggers the evaluate-embargo behavior for the given actor. " + "Accepts the current (or specified) embargo proposal by emitting " + "an EmAcceptEmbargoActivity activity. Activates the embargo on the case " + "(EM state → ACTIVE). " + "Returns the resulting activity in the response body (TB-04-001)." + ), +) +def trigger_evaluate_embargo( + actor_id: str, + body: EvaluateEmbargoRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the evaluate-embargo (accept) behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, TB-03-002, + TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_evaluate_embargo(actor_id, body.case_id, body.proposal_id, dl) + + +@router.post( + "/{actor_id}/trigger/terminate-embargo", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger embargo termination.", + description=( + "Triggers the terminate-embargo behavior for the given actor. " + "Announces the end of the active embargo by emitting an " + "AnnounceEmbargoActivity activity. Updates the case EM state to EXITED " + "and clears the active embargo. " + "Returns HTTP 409 if no active embargo exists. " + "Returns the resulting activity in the response body (TB-04-001)." + ), +) +def trigger_terminate_embargo( + actor_id: str, + body: TerminateEmbargoRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the terminate-embargo behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, TB-03-002, + TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_terminate_embargo(actor_id, body.case_id, dl) diff --git a/vultron/api/v2/routers/trigger_report.py b/vultron/api/v2/routers/trigger_report.py new file mode 100644 index 00000000..480baf9b --- /dev/null +++ b/vultron/api/v2/routers/trigger_report.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +Trigger router for report-management behaviors. + +Thin wrapper: validates request → calls service → returns response. +All domain logic lives in vultron.api.v2.backend.trigger_services.report. +""" + +from fastapi import APIRouter, Depends, status + +from vultron.api.v2.backend.trigger_services._models import ( + CloseReportRequest, + InvalidateReportRequest, + RejectReportRequest, + ValidateReportRequest, +) +from vultron.api.v2.backend.trigger_services.report import ( + svc_close_report, + svc_invalidate_report, + svc_reject_report, + svc_validate_report, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.adapters.driven.datalayer_tinydb import get_datalayer + +router = APIRouter(prefix="/actors", tags=["Triggers"]) + + +@router.post( + "/{actor_id}/trigger/validate-report", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger report validation.", + description=( + "Triggers the validate-report behavior for the given actor. " + "Invokes the ValidateReportBT tree via the bridge layer and " + "returns the resulting ActivityStreams activity (TB-04-001)." + ), +) +def trigger_validate_report( + actor_id: str, + body: ValidateReportRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the validate-report behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-03-001, TB-03-002, TB-03-003, + TB-04-001, TB-05-001, TB-05-002, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_validate_report(actor_id, body.offer_id, body.note, dl) + + +@router.post( + "/{actor_id}/trigger/invalidate-report", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger report invalidation.", + description=( + "Triggers the invalidate-report behavior for the given actor. " + "Emits a TentativeReject(Offer(VulnerabilityReport)) activity " + "(RmInvalidateReportActivity) and returns it in the response body (TB-04-001). " + "Updates the offer status to TENTATIVELY_REJECTED and the report " + "status to INVALID." + ), +) +def trigger_invalidate_report( + actor_id: str, + body: InvalidateReportRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the invalidate-report behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, + TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_invalidate_report(actor_id, body.offer_id, body.note, dl) + + +@router.post( + "/{actor_id}/trigger/reject-report", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger hard-close of a report.", + description=( + "Triggers the reject-report behavior for the given actor. " + "Emits a Reject(Offer(VulnerabilityReport)) activity (RmCloseReportActivity) " + "and returns it in the response body (TB-04-001). " + "A non-empty note is required (TB-03-004). " + "Updates the offer status to REJECTED and the report status to CLOSED." + ), +) +def trigger_reject_report( + actor_id: str, + body: RejectReportRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the reject-report (hard-close) behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, + TB-03-004, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_reject_report(actor_id, body.offer_id, body.note, dl) + + +@router.post( + "/{actor_id}/trigger/close-report", + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger RM lifecycle closure of a report.", + description=( + "Triggers the close-report behavior for the given actor. " + "Emits a Reject(Offer(VulnerabilityReport)) activity (RmCloseReportActivity) " + "representing the RM → C (CLOSED) transition, and returns it in the " + "response body (TB-04-001). " + "Updates the offer status to REJECTED and the report status to CLOSED. " + "Unlike reject-report (which hard-rejects before validation), this " + "endpoint closes a report that has already progressed through the RM " + "lifecycle. Returns HTTP 409 if the report is already CLOSED." + ), +) +def trigger_close_report( + actor_id: str, + body: CloseReportRequest, + dl: DataLayer = Depends(get_datalayer), +) -> dict: + """ + Trigger the close-report (RM → CLOSED) behavior for the given actor. + + Implements: + TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, + TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 + """ + return svc_close_report(actor_id, body.offer_id, body.note, dl) diff --git a/vultron/api/v2/routers/triggers.py b/vultron/api/v2/routers/triggers.py deleted file mode 100644 index 4a551507..00000000 --- a/vultron/api/v2/routers/triggers.py +++ /dev/null @@ -1,1274 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2026 Carnegie Mellon University and Contributors. -# - see Contributors.md for a full list of Contributors -# - see ContributionInstructions.md for information on how you can Contribute to this project -# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is -# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed -# with this Software or contact permission@sei.cmu.edu for full terms. -# Created, in part, with funding and support from the United States Government -# (see Acknowledgments file). This program may include and/or can make use of -# certain third party source code, object code, documentation and other files -# ("Third Party Software"). See LICENSE.md for more details. -# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the -# U.S. Patent and Trademark Office by Carnegie Mellon University - -""" -Trigger endpoints for actor-initiated Vultron behaviors. - -Implements POST /actors/{actor_id}/trigger/{behavior-name} for behaviors -a local actor initiates based on their own state rather than reacting to -an inbound message (the outgoing counterpart to the inbound handler pipeline). - -Per specs/triggerable-behaviors.md (TRG prefix) TB-01 through TB-07. -Per notes/triggerable-behaviors.md design notes. - -Naming convention (per notes/triggerable-behaviors.md §4): - trigger_* — local actor decides to initiate a behavior (outbound) - handle_* — processes an inbound message (reactive, in handlers/) -""" - -import logging -from datetime import datetime - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, ConfigDict, field_validator - -from vultron.api.v2.data.rehydration import rehydrate -from vultron.api.v2.data.status import ( - OfferStatus, - ReportStatus, - get_status_layer, - set_status, -) -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.api.v2.datalayer.tinydb_backend import get_datalayer -from vultron.as_vocab.activities.case import RmDeferCase, RmEngageCase -from vultron.as_vocab.activities.embargo import ( - AnnounceEmbargo, - EmAcceptEmbargo, - EmProposeEmbargo, -) -from vultron.as_vocab.activities.report import ( - RmCloseReport, - RmInvalidateReport, -) -from vultron.as_vocab.objects.case_status import ParticipantStatus -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.behaviors.bridge import BTBridge -from vultron.behaviors.report.validate_tree import create_validate_report_tree -from vultron.bt.embargo_management.states import EM -from vultron.bt.report_management.states import RM, RM_CLOSABLE -from vultron.enums import OfferStatusEnum - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/actors", tags=["Triggers"]) - - -# --------------------------------------------------------------------------- -# Request models -# --------------------------------------------------------------------------- - - -class ValidateReportRequest(BaseModel): - """ - Request body for the validate-report trigger endpoint. - - TB-03-001: Must include offer_id to identify the target offer. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - TB-03-003: Optional note field may be included. - """ - - model_config = ConfigDict(extra="ignore") - - offer_id: str - note: str | None = None - - -class InvalidateReportRequest(BaseModel): - """ - Request body for the invalidate-report trigger endpoint. - - TB-03-001: Must include offer_id to identify the target offer. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - TB-03-003: Optional note field may be included. - """ - - model_config = ConfigDict(extra="ignore") - - offer_id: str - note: str | None = None - - -class CaseTriggerRequest(BaseModel): - """ - Request body for case-level trigger endpoints. - - TB-03-001: Must include case_id to identify the target case. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - """ - - model_config = ConfigDict(extra="ignore") - - case_id: str - - -class CloseReportRequest(BaseModel): - """ - Request body for the close-report trigger endpoint. - - TB-03-001: Must include offer_id to identify the target offer. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - TB-03-003: Optional note field may be included. - - Distinction from reject-report: close-report closes a report after the - RM lifecycle has proceeded (RM → C transition; emits RC message), while - reject-report hard-rejects an incoming offer before validation completes. - """ - - model_config = ConfigDict(extra="ignore") - - offer_id: str - note: str | None = None - - -class RejectReportRequest(BaseModel): - """ - Request body for the reject-report trigger endpoint. - - TB-03-001: Must include offer_id to identify the target offer. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - TB-03-004: note is required (hard-close decisions warrant documented - justification); an empty note emits a WARNING. - """ - - model_config = ConfigDict(extra="ignore") - - offer_id: str - note: str - - @field_validator("note") - @classmethod - def note_must_be_present(cls, v: str) -> str: - # TB-03-004: note SHOULD be non-empty; warn but accept empty string - if not v.strip(): - logger.warning( - "reject-report trigger received an empty note field; " - "hard-close decisions should include a documented reason." - ) - return v - - -class ProposeEmbargoRequest(BaseModel): - """ - Request body for the propose-embargo trigger endpoint. - - TB-03-001: Must include case_id to identify the target case. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - TB-03-003: Optional note field may be included. - """ - - model_config = ConfigDict(extra="ignore") - - case_id: str - note: str | None = None - end_time: datetime | None = None - - -class EvaluateEmbargoRequest(BaseModel): - """ - Request body for the evaluate-embargo trigger endpoint. - - TB-03-001: Must include case_id to identify the target case. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - Optional proposal_id identifies the specific EmProposeEmbargo to accept; - if omitted, the first pending proposal for the case is used. - """ - - model_config = ConfigDict(extra="ignore") - - case_id: str - proposal_id: str | None = None - - -class TerminateEmbargoRequest(BaseModel): - """ - Request body for the terminate-embargo trigger endpoint. - - TB-03-001: Must include case_id to identify the target case. - TB-03-002: Unknown fields are silently ignored (extra="ignore"). - """ - - model_config = ConfigDict(extra="ignore") - - case_id: str - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _not_found(resource_type: str, resource_id: str) -> HTTPException: - """Return a structured 404 per EH-05-001.""" - return HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={ - "status": 404, - "error": "NotFound", - "message": f"{resource_type} '{resource_id}' not found.", - "activity_id": None, - }, - ) - - -def _resolve_actor(actor_id: str, dl: DataLayer): - """Resolve actor by full ID or short ID; raise 404 if absent.""" - actor = dl.read(actor_id) - if actor is None: - actor = dl.find_actor_by_short_id(actor_id) - if actor is None: - raise _not_found("Actor", actor_id) - return actor - - -def _resolve_case(case_id: str, dl: DataLayer) -> VulnerabilityCase: - """Resolve a VulnerabilityCase by ID; raise 404 if absent or wrong type.""" - case_raw = dl.read(case_id) - if case_raw is None: - raise _not_found("VulnerabilityCase", case_id) - if not isinstance(case_raw, VulnerabilityCase): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": ( - f"Expected VulnerabilityCase, got " - f"{type(case_raw).__name__}." - ), - "activity_id": None, - }, - ) - return case_raw - - -def _update_participant_rm_state( - case_id: str, actor_id: str, new_rm_state: RM, dl: DataLayer -) -> None: - """ - Append a new ParticipantStatus with new_rm_state to the actor's - CaseParticipant in the given case and persist the updated case. - - Logs a WARNING and returns without error if no participant record is found - (non-blocking for the outgoing trigger case; participant may be created - separately). - """ - case_obj = dl.read(case_id) - if case_obj is None or not isinstance(case_obj, VulnerabilityCase): - logger.warning( - "_update_participant_rm_state: case '%s' not found or wrong type", - case_id, - ) - return - - for participant_ref in case_obj.case_participants: - if isinstance(participant_ref, str): - participant = dl.read(participant_ref) - if participant is None: - continue - else: - participant = participant_ref - - actor_ref = participant.attributed_to - p_actor_id = ( - actor_ref - if isinstance(actor_ref, str) - else getattr(actor_ref, "as_id", str(actor_ref)) - ) - if p_actor_id == actor_id: - if participant.participant_statuses: - latest = participant.participant_statuses[-1] - if latest.rm_state == new_rm_state: - logger.info( - "Participant '%s' already in RM state %s in case '%s' " - "(idempotent)", - actor_id, - new_rm_state, - case_id, - ) - return - new_status = ParticipantStatus( - actor=actor_id, - context=case_id, - rm_state=new_rm_state, - ) - participant.participant_statuses.append(new_status) - dl.update(participant.as_id, object_to_record(participant)) - logger.info( - "Set participant '%s' RM state to %s in case '%s'", - actor_id, - new_rm_state, - case_id, - ) - return - - logger.warning( - "_update_participant_rm_state: no CaseParticipant for actor '%s' " - "in case '%s'; RM state not updated", - actor_id, - case_id, - ) - - -def _outbox_ids(actor) -> set[str]: - """Return the set of string activity IDs in actor.outbox.items.""" - if not (hasattr(actor, "outbox") and actor.outbox and actor.outbox.items): - return set() - return {item for item in actor.outbox.items if isinstance(item, str)} - - -def _find_embargo_proposal(case_id: str, dl: DataLayer): - """ - Find the first stored EmProposeEmbargo activity for the given case. - - Scans all Invite-typed objects in the DataLayer and returns the first - whose context matches case_id and whose object is an EmbargoEvent. - Returns None if no matching proposal is found. - """ - invite_records = dl.by_type("Invite") - for obj_id in invite_records: - obj = dl.read(obj_id) - if obj is None: - continue - context = obj.context - c_id = ( - context - if isinstance(context, str) - else getattr(context, "as_id", str(context)) - ) - if c_id != case_id: - continue - embargo_ref = getattr(obj, "as_object", None) - if embargo_ref is None: - continue - embargo_id = ( - embargo_ref - if isinstance(embargo_ref, str) - else getattr(embargo_ref, "as_id", None) - ) - if embargo_id is None: - continue - emb = dl.read(embargo_id) - if emb is not None and str(getattr(emb, "as_type", "")) == "Event": - return obj - return None - - -# --------------------------------------------------------------------------- -# Endpoints -# --------------------------------------------------------------------------- - - -@router.post( - "/{actor_id}/trigger/validate-report", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger report validation.", - description=( - "Triggers the validate-report behavior for the given actor. " - "Invokes the ValidateReportBT tree via the bridge layer and " - "returns the resulting ActivityStreams activity (TB-04-001)." - ), -) -def trigger_validate_report( - actor_id: str, - body: ValidateReportRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the validate-report behavior for the given actor. - - Resolves the actor and offer, invokes the ValidateReportBT tree via - the bridge layer, and returns HTTP 202 with the resulting activity. - - Trigger processing is synchronous: BT execution completes before the - response is returned, so the response body includes the resulting - activity directly (per notes/triggerable-behaviors.md and TB-04-001). - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-03-001, TB-03-002, TB-03-003, - TB-04-001, TB-05-001, TB-05-002, TB-06-001, TB-06-002, TB-07-001 - """ - # TB-01-003: Resolve actor or return structured 404 - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - # TB-01-003: Resolve offer or return structured 404 - offer_raw = dl.read(body.offer_id) - if offer_raw is None: - raise _not_found("Offer", body.offer_id) - - try: - offer = rehydrate(offer_raw) - report = rehydrate(offer.as_object) - except (ValueError, KeyError, AttributeError) as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": str(e), - "activity_id": None, - }, - ) - - if not isinstance(report, VulnerabilityReport): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": ( - f"Expected VulnerabilityReport, got " - f"{type(report).__name__}." - ), - "activity_id": None, - }, - ) - - report_id = report.as_id - offer_id = offer.as_id - - # Snapshot outbox before execution to detect new activities (TB-07-001) - outbox_before = _outbox_ids(actor) - - # TB-05-001, TB-05-002: Invoke BT via bridge layer - bridge = BTBridge(datalayer=dl) - tree = create_validate_report_tree(report_id=report_id, offer_id=offer_id) - - context = {} - if body.note: - context["note"] = body.note - - bridge.execute_with_setup(tree, actor_id=actor_id, **context) - - # TB-07-001: BT adds resulting activity to actor's outbox; retrieve it - activity = None - actor_after = dl.read(actor_id) - if actor_after is not None: - outbox_after = _outbox_ids(actor_after) - new_items = outbox_after - outbox_before - if new_items: - activity_id = next(iter(new_items)) - activity_obj = dl.read(activity_id) - if activity_obj is not None: - activity = activity_obj.model_dump( - by_alias=True, exclude_none=True - ) - - # TB-04-001: Return 202 with {"activity": {...}} - return {"activity": activity} - - -def _add_activity_to_outbox( - actor_id: str, activity_id: str, dl: DataLayer -) -> None: - """Append an activity ID to an actor's outbox and persist the actor.""" - actor_obj = dl.read(actor_id) - if actor_obj is None: - logger.error("_add_activity_to_outbox: actor '%s' not found", actor_id) - return - if not (hasattr(actor_obj, "outbox") and actor_obj.outbox is not None): - logger.error( - "_add_activity_to_outbox: actor '%s' has no outbox", actor_id - ) - return - actor_obj.outbox.items.append(activity_id) - dl.update(actor_obj.as_id, object_to_record(actor_obj)) - logger.debug( - "Added activity '%s' to actor '%s' outbox", activity_id, actor_id - ) - - -@router.post( - "/{actor_id}/trigger/invalidate-report", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger report invalidation.", - description=( - "Triggers the invalidate-report behavior for the given actor. " - "Emits a TentativeReject(Offer(VulnerabilityReport)) activity " - "(RmInvalidateReport) and returns it in the response body (TB-04-001). " - "Updates the offer status to TENTATIVELY_REJECTED and the report " - "status to INVALID." - ), -) -def trigger_invalidate_report( - actor_id: str, - body: InvalidateReportRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the invalidate-report behavior for the given actor. - - Emits RmInvalidateReport (TentativeReject) for the given offer, - updates local state, adds to actor outbox, and returns HTTP 202. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, - TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - offer_raw = dl.read(body.offer_id) - if offer_raw is None: - raise _not_found("Offer", body.offer_id) - - try: - offer = rehydrate(offer_raw) - report = rehydrate(offer.as_object) - except (ValueError, KeyError, AttributeError) as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": str(e), - "activity_id": None, - }, - ) - - if not isinstance(report, VulnerabilityReport): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": ( - f"Expected VulnerabilityReport, got " - f"{type(report).__name__}." - ), - "activity_id": None, - }, - ) - - invalidate_activity = RmInvalidateReport( - actor=actor_id, - object=offer.as_id, - ) - - try: - dl.create(invalidate_activity) - except ValueError: - logger.warning( - "InvalidateReport activity '%s' already exists", - invalidate_activity.as_id, - ) - - set_status( - OfferStatus( - object_type=offer.as_type, - object_id=offer.as_id, - status=OfferStatusEnum.TENTATIVELY_REJECTED, - actor_id=actor_id, - ) - ) - set_status( - ReportStatus( - object_type=report.as_type, - object_id=report.as_id, - status=RM.INVALID, - actor_id=actor_id, - ) - ) - - _add_activity_to_outbox(actor_id, invalidate_activity.as_id, dl) - - logger.info( - "Actor '%s' invalidated offer '%s' (report '%s')", - actor_id, - offer.as_id, - report.as_id, - ) - - activity = invalidate_activity.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -@router.post( - "/{actor_id}/trigger/reject-report", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger hard-close of a report.", - description=( - "Triggers the reject-report behavior for the given actor. " - "Emits a Reject(Offer(VulnerabilityReport)) activity (RmCloseReport) " - "and returns it in the response body (TB-04-001). " - "A non-empty note is required (TB-03-004). " - "Updates the offer status to REJECTED and the report status to CLOSED." - ), -) -def trigger_reject_report( - actor_id: str, - body: RejectReportRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the reject-report (hard-close) behavior for the given actor. - - Emits RmCloseReport (Reject) for the given offer, updates local state, - adds to actor outbox, and returns HTTP 202. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, - TB-03-004, TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - offer_raw = dl.read(body.offer_id) - if offer_raw is None: - raise _not_found("Offer", body.offer_id) - - try: - offer = rehydrate(offer_raw) - report = rehydrate(offer.as_object) - except (ValueError, KeyError, AttributeError) as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": str(e), - "activity_id": None, - }, - ) - - if not isinstance(report, VulnerabilityReport): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": ( - f"Expected VulnerabilityReport, got " - f"{type(report).__name__}." - ), - "activity_id": None, - }, - ) - - reject_activity = RmCloseReport( - actor=actor_id, - object=offer.as_id, - ) - - try: - dl.create(reject_activity) - except ValueError: - logger.warning( - "CloseReport activity '%s' already exists", reject_activity.as_id - ) - - set_status( - OfferStatus( - object_type=offer.as_type, - object_id=offer.as_id, - status=OfferStatusEnum.REJECTED, - actor_id=actor_id, - ) - ) - set_status( - ReportStatus( - object_type=report.as_type, - object_id=report.as_id, - status=RM.CLOSED, - actor_id=actor_id, - ) - ) - - _add_activity_to_outbox(actor_id, reject_activity.as_id, dl) - - logger.info( - "Actor '%s' hard-closed offer '%s' (report '%s'); note: %s", - actor_id, - offer.as_id, - report.as_id, - body.note, - ) - - activity = reject_activity.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -@router.post( - "/{actor_id}/trigger/engage-case", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger case engagement.", - description=( - "Triggers the engage-case behavior for the given actor. " - "Emits a Join(VulnerabilityCase) activity (RmEngageCase), " - "transitions the actor's RM state to ACCEPTED in the case, " - "and returns the activity in the response body (TB-04-001)." - ), -) -def trigger_engage_case( - actor_id: str, - body: CaseTriggerRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the engage-case behavior for the given actor. - - The local actor decides to engage the case (RM → ACCEPTED). Emits - RmEngageCase (Join(VulnerabilityCase)), updates the actor's own - CaseParticipant RM state, adds to actor outbox, and returns HTTP 202. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, - TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - case = _resolve_case(body.case_id, dl) - - engage_activity = RmEngageCase( - actor=actor_id, - object=case.as_id, - ) - - try: - dl.create(engage_activity) - except ValueError: - logger.warning( - "EngageCase activity '%s' already exists", engage_activity.as_id - ) - - _update_participant_rm_state(case.as_id, actor_id, RM.ACCEPTED, dl) - - _add_activity_to_outbox(actor_id, engage_activity.as_id, dl) - - logger.info( - "Actor '%s' engaged case '%s' (RM → ACCEPTED)", - actor_id, - case.as_id, - ) - - activity = engage_activity.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -@router.post( - "/{actor_id}/trigger/defer-case", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger case deferral.", - description=( - "Triggers the defer-case behavior for the given actor. " - "Emits an Ignore(VulnerabilityCase) activity (RmDeferCase), " - "transitions the actor's RM state to DEFERRED in the case, " - "and returns the activity in the response body (TB-04-001)." - ), -) -def trigger_defer_case( - actor_id: str, - body: CaseTriggerRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the defer-case behavior for the given actor. - - The local actor decides to defer the case (RM → DEFERRED). Emits - RmDeferCase (Ignore(VulnerabilityCase)), updates the actor's own - CaseParticipant RM state, adds to actor outbox, and returns HTTP 202. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, - TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - case = _resolve_case(body.case_id, dl) - - defer_activity = RmDeferCase( - actor=actor_id, - object=case.as_id, - ) - - try: - dl.create(defer_activity) - except ValueError: - logger.warning( - "DeferCase activity '%s' already exists", defer_activity.as_id - ) - - _update_participant_rm_state(case.as_id, actor_id, RM.DEFERRED, dl) - - _add_activity_to_outbox(actor_id, defer_activity.as_id, dl) - - logger.info( - "Actor '%s' deferred case '%s' (RM → DEFERRED)", - actor_id, - case.as_id, - ) - - activity = defer_activity.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -# `RM_CLOSABLE` (imported from states) defines valid RM states for closure. - - -@router.post( - "/{actor_id}/trigger/close-report", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger RM lifecycle closure of a report.", - description=( - "Triggers the close-report behavior for the given actor. " - "Emits a Reject(Offer(VulnerabilityReport)) activity (RmCloseReport) " - "representing the RM → C (CLOSED) transition, and returns it in the " - "response body (TB-04-001). " - "Updates the offer status to REJECTED and the report status to CLOSED. " - "Unlike reject-report (which hard-rejects before validation), this " - "endpoint closes a report that has already progressed through the RM " - "lifecycle. Returns HTTP 409 if the report is already CLOSED." - ), -) -def trigger_close_report( - actor_id: str, - body: CloseReportRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the close-report (RM → CLOSED) behavior for the given actor. - - Emits RmCloseReport (Reject) for the given offer, updates local state, - adds to actor outbox, and returns HTTP 202. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-001, TB-03-001, TB-03-002, - TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - offer_raw = dl.read(body.offer_id) - if offer_raw is None: - raise _not_found("Offer", body.offer_id) - - try: - offer = rehydrate(offer_raw) - report = rehydrate(offer.as_object) - except (ValueError, KeyError, AttributeError) as e: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": str(e), - "activity_id": None, - }, - ) - - if not isinstance(report, VulnerabilityReport): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": ( - f"Expected VulnerabilityReport, got " - f"{type(report).__name__}." - ), - "activity_id": None, - }, - ) - - status_layer = get_status_layer() - type_dict = status_layer.get(report.as_type, {}) - id_dict = type_dict.get(report.as_id, {}) - actor_status_dict = id_dict.get(actor_id, {}) - current_rm_state = actor_status_dict.get("status") - - if current_rm_state == RM.CLOSED: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "status": 409, - "error": "Conflict", - "message": (f"Report '{report.as_id}' is already CLOSED."), - "activity_id": None, - }, - ) - - close_activity = RmCloseReport( - actor=actor_id, - object=offer.as_id, - ) - - try: - dl.create(close_activity) - except ValueError: - logger.warning( - "CloseReport activity '%s' already exists", close_activity.as_id - ) - - set_status( - OfferStatus( - object_type=offer.as_type, - object_id=offer.as_id, - status=OfferStatusEnum.REJECTED, - actor_id=actor_id, - ) - ) - set_status( - ReportStatus( - object_type=report.as_type, - object_id=report.as_id, - status=RM.CLOSED, - actor_id=actor_id, - ) - ) - - _add_activity_to_outbox(actor_id, close_activity.as_id, dl) - - logger.info( - "Actor '%s' closed offer '%s' (report '%s') via RM lifecycle; note: %s", - actor_id, - offer.as_id, - report.as_id, - body.note, - ) - - activity = close_activity.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -# =========================================================================== -# EM Trigger Endpoints (P30-5) -# =========================================================================== - - -@router.post( - "/{actor_id}/trigger/propose-embargo", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger an embargo proposal.", - description=( - "Triggers the propose-embargo behavior for the given actor. " - "Creates a new EmbargoEvent and emits an EmProposeEmbargo " - "(Invite(EmbargoEvent)) activity. " - "EM state transitions: N → P (new proposal) or A → R (revision). " - "Returns the resulting activity in the response body (TB-04-001)." - ), -) -def trigger_propose_embargo( - actor_id: str, - body: ProposeEmbargoRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the propose-embargo behavior for the given actor. - - The local actor decides to propose an embargo on the given case. - Creates an EmbargoEvent, emits EmProposeEmbargo, updates local EM state, - adds to actor outbox, and returns HTTP 202. - - EM state transitions per em_propose_bt.md: - - EM.N → EM.P (first proposal; emits EP) - - EM.A → EM.R (revision proposal; emits EV) - - EM.P or EM.R: counter-proposal; no state change - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, TB-03-002, - TB-03-003, TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - case = _resolve_case(body.case_id, dl) - - em_state = case.current_status.em_state - - if em_state == EM.EXITED: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "status": 409, - "error": "Conflict", - "message": ( - f"Cannot propose embargo: case '{case.as_id}' EM state " - f"is EXITED." - ), - "activity_id": None, - }, - ) - - embargo_kwargs: dict = {"context": case.as_id} - if body.end_time is not None: - embargo_kwargs["end_time"] = body.end_time - - embargo = EmbargoEvent(**embargo_kwargs) - - try: - dl.create(embargo) - except ValueError: - logger.warning("EmbargoEvent '%s' already exists", embargo.as_id) - - proposal = EmProposeEmbargo( - actor=actor_id, - object=embargo.as_id, - context=case.as_id, - ) - - try: - dl.create(proposal) - except ValueError: - logger.warning("EmProposeEmbargo '%s' already exists", proposal.as_id) - - if em_state == EM.NO_EMBARGO: - case.current_status.em_state = EM.PROPOSED - logger.info( - "Actor '%s' proposed embargo '%s' on case '%s' (EM N → P)", - actor_id, - embargo.as_id, - case.as_id, - ) - elif em_state == EM.ACTIVE: - case.current_status.em_state = EM.REVISE - logger.info( - "Actor '%s' proposed embargo revision '%s' on case '%s' (EM A → R)", - actor_id, - embargo.as_id, - case.as_id, - ) - else: - logger.info( - "Actor '%s' counter-proposed embargo '%s' on case '%s' (EM %s, no state change)", - actor_id, - embargo.as_id, - case.as_id, - em_state, - ) - - case.proposed_embargoes.append(embargo.as_id) - dl.update(case.as_id, object_to_record(case)) - - _add_activity_to_outbox(actor_id, proposal.as_id, dl) - - activity = proposal.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -@router.post( - "/{actor_id}/trigger/evaluate-embargo", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger embargo evaluation (accept a proposal).", - description=( - "Triggers the evaluate-embargo behavior for the given actor. " - "Accepts the current (or specified) embargo proposal by emitting " - "an EmAcceptEmbargo activity. Activates the embargo on the case " - "(EM state → ACTIVE). " - "Returns the resulting activity in the response body (TB-04-001)." - ), -) -def trigger_evaluate_embargo( - actor_id: str, - body: EvaluateEmbargoRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the evaluate-embargo (accept) behavior for the given actor. - - The local actor accepts the current embargo proposal. Emits - EmAcceptEmbargo (Accept(EmProposeEmbargo)), activates the embargo - on the case (EM → ACTIVE), adds to actor outbox, and returns HTTP 202. - - If proposal_id is omitted, the first pending EmProposeEmbargo for the - case is used. Returns HTTP 404 if no proposal is found. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, TB-03-002, - TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - case = _resolve_case(body.case_id, dl) - - if body.proposal_id: - proposal_raw = dl.read(body.proposal_id) - if proposal_raw is None: - raise _not_found("EmbargoProposal", body.proposal_id) - proposal = proposal_raw - else: - proposal = _find_embargo_proposal(case.as_id, dl) - if proposal is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={ - "status": 404, - "error": "NotFound", - "message": ( - f"No pending embargo proposal found for case " - f"'{case.as_id}'." - ), - "activity_id": None, - }, - ) - - if str(getattr(proposal, "as_type", "")) != "Invite": - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": ( - f"Expected an Invite (embargo proposal), got " - f"{type(proposal).__name__}." - ), - "activity_id": None, - }, - ) - - embargo_ref = getattr(proposal, "as_object", None) - embargo_id = ( - embargo_ref - if isinstance(embargo_ref, str) - else getattr(embargo_ref, "as_id", None) - ) - if not embargo_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": "Proposal is missing an embargo event reference.", - "activity_id": None, - }, - ) - embargo = dl.read(embargo_id) - if embargo is None or str(getattr(embargo, "as_type", "")) != "Event": - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "status": 422, - "error": "ValidationError", - "message": f"Could not resolve EmbargoEvent '{embargo_id}'.", - "activity_id": None, - }, - ) - - accept = EmAcceptEmbargo( - actor=actor_id, - object=proposal.as_id, - context=case.as_id, - ) - - try: - dl.create(accept) - except ValueError: - logger.warning("EmAcceptEmbargo '%s' already exists", accept.as_id) - - case.set_embargo(embargo_id) - dl.update(case.as_id, object_to_record(case)) - - _add_activity_to_outbox(actor_id, accept.as_id, dl) - - logger.info( - "Actor '%s' accepted embargo proposal '%s'; activated embargo '%s' " - "on case '%s' (EM → ACTIVE)", - actor_id, - proposal.as_id, - embargo_id, - case.as_id, - ) - - activity = accept.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} - - -@router.post( - "/{actor_id}/trigger/terminate-embargo", - status_code=status.HTTP_202_ACCEPTED, - summary="Trigger embargo termination.", - description=( - "Triggers the terminate-embargo behavior for the given actor. " - "Announces the end of the active embargo by emitting an " - "AnnounceEmbargo activity. Updates the case EM state to EXITED " - "and clears the active embargo. " - "Returns HTTP 409 if no active embargo exists. " - "Returns the resulting activity in the response body (TB-04-001)." - ), -) -def trigger_terminate_embargo( - actor_id: str, - body: TerminateEmbargoRequest, - dl: DataLayer = Depends(get_datalayer), -) -> dict: - """ - Trigger the terminate-embargo behavior for the given actor. - - The local actor announces the termination of the active embargo. - Emits AnnounceEmbargo (ET message), sets case EM state to EXITED, - clears the active embargo, adds to actor outbox, and returns HTTP 202. - - Returns HTTP 409 if the case has no active embargo to terminate. - - Implements: - TB-01-001, TB-01-002, TB-01-003, TB-02-002, TB-03-001, TB-03-002, - TB-04-001, TB-06-001, TB-06-002, TB-07-001 - """ - actor = _resolve_actor(actor_id, dl) - actor_id = actor.as_id - - case = _resolve_case(body.case_id, dl) - - if case.active_embargo is None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "status": 409, - "error": "Conflict", - "message": ( - f"Case '{case.as_id}' has no active embargo to terminate." - ), - "activity_id": None, - }, - ) - - embargo_id = ( - case.active_embargo - if isinstance(case.active_embargo, str) - else case.active_embargo.as_id - ) - - announce = AnnounceEmbargo( - actor=actor_id, - object=embargo_id, - context=case.as_id, - ) - - try: - dl.create(announce) - except ValueError: - logger.warning("AnnounceEmbargo '%s' already exists", announce.as_id) - - case.current_status.em_state = EM.EXITED - case.active_embargo = None - dl.update(case.as_id, object_to_record(case)) - - _add_activity_to_outbox(actor_id, announce.as_id, dl) - - logger.info( - "Actor '%s' terminated embargo '%s' on case '%s' (EM → EXITED)", - actor_id, - embargo_id, - case.as_id, - ) - - activity = announce.model_dump(by_alias=True, exclude_none=True) - return {"activity": activity} diff --git a/vultron/api/v2/routers/v2_router.py b/vultron/api/v2/routers/v2_router.py index 8e1f9a9e..5dfc6751 100644 --- a/vultron/api/v2/routers/v2_router.py +++ b/vultron/api/v2/routers/v2_router.py @@ -20,10 +20,12 @@ from vultron.api.v2.routers import ( actors, - examples, datalayer, + examples, health, - triggers, + trigger_case, + trigger_embargo, + trigger_report, ) router = APIRouter() @@ -43,4 +45,8 @@ def get_version(request: Request): router.include_router(health.router) -router.include_router(triggers.router) +router.include_router(trigger_report.router) + +router.include_router(trigger_case.router) + +router.include_router(trigger_embargo.router) diff --git a/vultron/behavior_dispatcher.py b/vultron/behavior_dispatcher.py index fcd62cd9..f690619f 100644 --- a/vultron/behavior_dispatcher.py +++ b/vultron/behavior_dispatcher.py @@ -3,39 +3,16 @@ """ import logging -from typing import Protocol +from typing import Protocol, TYPE_CHECKING +from vultron.core.models.events import MessageSemantics from vultron.dispatcher_errors import VultronApiHandlerNotFoundError -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.enums import MessageSemantics -from vultron.semantic_map import find_matching_semantics -from vultron.types import BehaviorHandler, DispatchActivity +from vultron.types import BehaviorHandler, DispatchEvent -logger = logging.getLogger(__name__) - - -def prepare_for_dispatch(activity: as_Activity) -> DispatchActivity: - """ - Prepares an activity for dispatch by extracting its message semantics and packaging it into a DispatchActivity. - """ - logger.debug( - f"Preparing activity '{activity.as_id}' of type '{activity.as_type}' for dispatch." - ) - - # We want dispatching to be simple and fast, so we only need to extract enough information - # to decide how to route the message. Any additional extraction can be downstream of the dispatcher. +if TYPE_CHECKING: + from vultron.core.ports.datalayer import DataLayer - data = { - "semantic_type": find_matching_semantics(activity=activity), - "activity_id": activity.as_id, - "payload": activity, - } - - dispatch_msg = DispatchActivity(**data) - logger.debug( - f"Prepared dispatch message with semantics '{dispatch_msg.semantic_type}' for activity '{dispatch_msg.payload.as_id}'" - ) - return dispatch_msg +logger = logging.getLogger(__name__) class ActivityDispatcher(Protocol): @@ -43,7 +20,7 @@ class ActivityDispatcher(Protocol): Protocol for dispatching activities to their corresponding _old_handlers based on message semantics. """ - def dispatch(self, dispatchable: DispatchActivity) -> None: + def dispatch(self, dispatchable: DispatchEvent) -> None: """Dispatches an activity to the appropriate handler based on its semantic type.""" ... @@ -53,24 +30,31 @@ class DispatcherBase(ActivityDispatcher): Base class for ActivityDispatcher implementations. Can include shared logic or utilities for dispatching. """ - def dispatch(self, dispatchable: DispatchActivity) -> None: - activity = dispatchable.payload + def __init__(self, handler_map: dict, dl: "DataLayer | None" = None): + self._handler_map = handler_map + self.dl = dl + + def dispatch(self, dispatchable: DispatchEvent) -> None: semantic_type = dispatchable.semantic_type logger.info( - f"Dispatching activity of type '{activity.as_type}' with semantics '{semantic_type}'" + f"Dispatching activity of type '{dispatchable.payload.object_type}' with semantics '{semantic_type}'" + ) + logger.debug( + f"Activity payload: activity_id={dispatchable.payload.activity_id} " + f"actor_id={dispatchable.payload.actor_id} " + f"object_type={dispatchable.payload.object_type}" ) - logger.debug(f"Activity payload: {activity.model_dump_json(indent=2)}") self._handle(dispatchable) - def _handle(self, dispatchable: DispatchActivity) -> None: + def _handle(self, dispatchable: DispatchEvent) -> None: """ Internal method to route the dispatchable activity to the correct handler based on its semantics. """ handler: BehaviorHandler = self._get_handler_for_semantics( dispatchable.semantic_type ) - handler(dispatchable=dispatchable) + handler(dispatchable=dispatchable, dl=self.dl) def _get_handler_for_semantics( self, semantics: MessageSemantics @@ -80,11 +64,7 @@ def _get_handler_for_semantics( Override this method if you want to implement a different way of mapping semantics to handlers (e.g. using a database or external service). """ - # Import lazily to avoid circular import - from vultron.semantic_handler_map import get_semantics_handlers - - handler_map = get_semantics_handlers() - handler_func = handler_map.get(semantics, None) + handler_func = self._handler_map.get(semantics, None) if handler_func is None: logger.error(f"No handler found for semantics '{semantics}'") @@ -103,9 +83,11 @@ class DirectActivityDispatcher(DispatcherBase): pass -def get_dispatcher() -> ActivityDispatcher: +def get_dispatcher( + handler_map: dict, dl: "DataLayer | None" = None +) -> ActivityDispatcher: """ Factory function to get an instance of the ActivityDispatcher. This allows for flexibility in swapping out different dispatcher implementations if needed. """ - return DirectActivityDispatcher() + return DirectActivityDispatcher(handler_map=handler_map, dl=dl) diff --git a/vultron/core/__init__.py b/vultron/core/__init__.py new file mode 100644 index 00000000..4b486999 --- /dev/null +++ b/vultron/core/__init__.py @@ -0,0 +1 @@ +"""Core domain layer for the Vultron Protocol.""" diff --git a/vultron/behaviors/__init__.py b/vultron/core/behaviors/__init__.py similarity index 100% rename from vultron/behaviors/__init__.py rename to vultron/core/behaviors/__init__.py diff --git a/vultron/behaviors/bridge.py b/vultron/core/behaviors/bridge.py similarity index 99% rename from vultron/behaviors/bridge.py rename to vultron/core/behaviors/bridge.py index 4f92d9e0..75f16cec 100644 --- a/vultron/behaviors/bridge.py +++ b/vultron/core/behaviors/bridge.py @@ -39,7 +39,7 @@ from py_trees.common import Status from py_trees.display import unicode_tree -from vultron.api.v2.datalayer.abc import DataLayer +from vultron.core.ports.datalayer import DataLayer logger = logging.getLogger(__name__) diff --git a/vultron/behaviors/case/__init__.py b/vultron/core/behaviors/case/__init__.py similarity index 100% rename from vultron/behaviors/case/__init__.py rename to vultron/core/behaviors/case/__init__.py diff --git a/vultron/behaviors/case/create_tree.py b/vultron/core/behaviors/case/create_tree.py similarity index 86% rename from vultron/behaviors/case/create_tree.py rename to vultron/core/behaviors/case/create_tree.py index 81c2367a..34dba347 100644 --- a/vultron/behaviors/case/create_tree.py +++ b/vultron/core/behaviors/case/create_tree.py @@ -30,9 +30,10 @@ ├─ ValidateCaseObject # Check required fields ├─ SetCaseAttributedTo # Set attributed_to to actor_id (CM-02-008) ├─ PersistCase # Save VulnerabilityCase to DataLayer + ├─ RecordCaseCreationEvents # Backfill offer_received + case_created events (CM-02-009) ├─ CreateInitialVendorParticipant # Add vendor as initial participant (CM-02-008) ├─ CreateCaseActorNode # Create CaseActor service (CM-02-001) - ├─ EmitCreateCaseActivity # Generate CreateCase activity + ├─ EmitCreateCaseActivity # Generate CreateCaseActivity activity └─ UpdateActorOutbox # Append activity to actor outbox """ @@ -40,13 +41,14 @@ import py_trees -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.behaviors.case.nodes import ( +from vultron.core.models.vultron_types import VultronCase +from vultron.core.behaviors.case.nodes import ( CheckCaseAlreadyExists, CreateCaseActorNode, CreateInitialVendorParticipant, EmitCreateCaseActivity, PersistCase, + RecordCaseCreationEvents, SetCaseAttributedTo, UpdateActorOutbox, ValidateCaseObject, @@ -56,21 +58,21 @@ def create_create_case_tree( - case_obj: VulnerabilityCase, + case_obj: VultronCase, actor_id: str, ) -> py_trees.behaviour.Behaviour: """ Create behavior tree for the create_case workflow. - Handles receipt of CreateCase (Create(VulnerabilityCase)): persists + Handles receipt of CreateCaseActivity (Create(VulnerabilityCase)): persists the case to the DataLayer, creates the associated CaseActor, and - emits a CreateCase activity to the actor outbox. + emits a CreateCaseActivity activity to the actor outbox. The root is a Selector so that if the case already exists the tree succeeds immediately (idempotency per ID-04-004). Args: - case_obj: VulnerabilityCase object extracted from the inbound + case_obj: Case domain object extracted from the inbound Create activity payload actor_id: ID of the receiving actor (case owner) @@ -86,6 +88,7 @@ def create_create_case_tree( ValidateCaseObject(case_obj=case_obj), SetCaseAttributedTo(case_obj=case_obj), PersistCase(case_obj=case_obj), + RecordCaseCreationEvents(case_obj=case_obj), CreateInitialVendorParticipant(case_obj=case_obj), CreateCaseActorNode(case_id=case_id, actor_id=actor_id), EmitCreateCaseActivity(), diff --git a/vultron/behaviors/case/nodes.py b/vultron/core/behaviors/case/nodes.py similarity index 75% rename from vultron/behaviors/case/nodes.py rename to vultron/core/behaviors/case/nodes.py index eef4f150..f1b98c49 100644 --- a/vultron/behaviors/case/nodes.py +++ b/vultron/core/behaviors/case/nodes.py @@ -30,12 +30,18 @@ import py_trees from py_trees.common import Status -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.as_vocab.activities.case import CreateCase as as_CreateCase -from vultron.as_vocab.objects.case_actor import CaseActor -from vultron.as_vocab.objects.case_participant import VendorParticipant -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.behaviors.helpers import DataLayerAction, DataLayerCondition +from vultron.core.models.vultron_types import ( + VultronCase, + VultronCaseActor, + VultronCreateCaseActivity, + VultronParticipant, +) +from vultron.bt.roles.states import CVDRoles +from vultron.core.behaviors.helpers import ( + DataLayerAction, + DataLayerCondition, + save_to_datalayer, +) logger = logging.getLogger(__name__) @@ -93,7 +99,7 @@ class ValidateCaseObject(DataLayerCondition): Returns FAILURE if required fields are missing. """ - def __init__(self, case_obj: VulnerabilityCase, name: str | None = None): + def __init__(self, case_obj: VultronCase, name: str | None = None): super().__init__(name=name or self.__class__.__name__) self.case_obj = case_obj @@ -132,7 +138,7 @@ class PersistCase(DataLayerAction): Per specs/case-management.md CM-02-001. """ - def __init__(self, case_obj: VulnerabilityCase, name: str | None = None): + def __init__(self, case_obj: VultronCase, name: str | None = None): super().__init__(name=name or self.__class__.__name__) self.case_obj = case_obj @@ -192,7 +198,7 @@ def update(self) -> Status: return Status.FAILURE try: - case_actor = CaseActor( + case_actor = VultronCaseActor( name=f"CaseActor for {self.case_id}", attributed_to=self.actor_id, context=self.case_id, @@ -218,10 +224,10 @@ def update(self) -> Status: class EmitCreateCaseActivity(DataLayerAction): """ - Generate a CreateCase activity and persist it to the DataLayer. + Generate a CreateCaseActivity activity and persist it to the DataLayer. Reads case_id from the blackboard (set by PersistCase), creates a - CreateCase activity, and stores the activity_id in the blackboard for + CreateCaseActivity activity, and stores the activity_id in the blackboard for UpdateActorOutbox. """ @@ -249,19 +255,19 @@ def update(self) -> Status: ) return Status.FAILURE - activity = as_CreateCase( + activity = VultronCreateCaseActivity( actor=self.actor_id, object=case_id, ) try: self.datalayer.create(activity) self.logger.info( - f"{self.name}: Created CreateCase activity" + f"{self.name}: Created CreateCaseActivity activity" f" {activity.as_id}" ) except ValueError as e: self.logger.warning( - f"{self.name}: CreateCase activity {activity.as_id}" + f"{self.name}: CreateCaseActivity activity {activity.as_id}" f" already exists: {e}" ) @@ -274,7 +280,7 @@ def update(self) -> Status: except Exception as e: self.logger.error( - f"{self.name}: Error creating CreateCase activity: {e}" + f"{self.name}: Error creating CreateCaseActivity activity: {e}" ) return Status.FAILURE @@ -289,7 +295,7 @@ class SetCaseAttributedTo(DataLayerAction): Per specs/case-management.md CM-02-008. """ - def __init__(self, case_obj: VulnerabilityCase, name: str | None = None): + def __init__(self, case_obj: VultronCase, name: str | None = None): super().__init__(name=name or self.__class__.__name__) self.case_obj = case_obj @@ -316,7 +322,7 @@ class CreateInitialVendorParticipant(DataLayerAction): Per specs/case-management.md CM-02-008 (SHOULD). """ - def __init__(self, case_obj: VulnerabilityCase, name: str | None = None): + def __init__(self, case_obj: VultronCase, name: str | None = None): super().__init__(name=name or self.__class__.__name__) self.case_obj = case_obj @@ -328,9 +334,10 @@ def update(self) -> Status: return Status.FAILURE try: - participant = VendorParticipant( + participant = VultronParticipant( attributed_to=self.actor_id, context=self.case_obj.as_id, + case_roles=[CVDRoles.VENDOR], ) if self.datalayer.read(participant.as_id) is None: self.datalayer.create(participant) @@ -358,9 +365,7 @@ def update(self) -> Status: } if participant.as_id not in existing_ids: stored_case.case_participants.append(participant.as_id) - self.datalayer.update( - stored_case.as_id, object_to_record(stored_case) - ) + save_to_datalayer(self.datalayer, stored_case) self.logger.info( f"{self.name}: Added VendorParticipant" f" {participant.as_id} to case {stored_case.as_id}" @@ -375,9 +380,92 @@ def update(self) -> Status: return Status.FAILURE +class RecordCaseCreationEvents(DataLayerAction): + """ + Backfill pre-case events into the case event log at case creation. + + Records a trusted-timestamp event for the case creation itself. + If the triggering activity has an ``in_reply_to`` reference (e.g. an + originating Offer), that event is also backfilled as an + ``"offer_received"`` entry. + + Must run after PersistCase so the case exists in the DataLayer. + Reads ``case_id`` and optionally ``activity`` from the blackboard. + + Per specs/case-management.md CM-02-009. + """ + + def __init__(self, case_obj: VultronCase, name: str | None = None): + super().__init__(name=name or self.__class__.__name__) + self.case_obj = case_obj + + def setup(self, **kwargs: Any) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="case_id", access=py_trees.common.Access.READ + ) + + def update(self) -> Status: + if self.datalayer is None: + self.logger.error(f"{self.name}: DataLayer not available") + return Status.FAILURE + + try: + case_id = self.blackboard.get("case_id") + if case_id is None: + self.logger.error( + f"{self.name}: case_id not found in blackboard" + ) + return Status.FAILURE + + case = self.datalayer.read(case_id) + if case is None: + self.logger.error( + f"{self.name}: Case {case_id} not found in DataLayer" + ) + return Status.FAILURE + + # Backfill originating offer receipt if available. + # Read directly from global storage — activity is optional and may + # not be written to the blackboard when the tree is invoked without + # an inbound activity. The blackboard storage key includes the root + # namespace prefix "/". + activity = py_trees.blackboard.Blackboard.storage.get( + "/activity", None + ) + if activity is not None: + offer_ref = getattr(activity, "in_reply_to", None) + if offer_ref is not None: + offer_id = ( + offer_ref.as_id + if hasattr(offer_ref, "as_id") + else str(offer_ref) + ) + case.record_event(offer_id, "offer_received") + self.logger.info( + f"{self.name}: Recorded offer_received event" + f" for {offer_id} on case {case_id}" + ) + + # Record the case creation event + case.record_event(case_id, "case_created") + self.logger.info( + f"{self.name}: Recorded case_created event on case {case_id}" + ) + + save_to_datalayer(self.datalayer, case) + return Status.SUCCESS + + except Exception as e: + self.logger.error( + f"{self.name}: Error recording case creation events: {e}" + ) + return Status.FAILURE + + class UpdateActorOutbox(DataLayerAction): """ - Append the CreateCase activity to the actor's outbox. + Append the CreateCaseActivity activity to the actor's outbox. Reads activity_id from blackboard (set by EmitCreateCaseActivity) and appends it to the actor's outbox.items list. @@ -420,7 +508,7 @@ def update(self) -> Status: return Status.FAILURE actor_obj.outbox.items.append(activity_id) - self.datalayer.update(actor_obj.as_id, object_to_record(actor_obj)) + save_to_datalayer(self.datalayer, actor_obj) self.logger.info( f"{self.name}: Added activity {activity_id} to" f" actor {self.actor_id} outbox" diff --git a/vultron/behaviors/helpers.py b/vultron/core/behaviors/helpers.py similarity index 89% rename from vultron/behaviors/helpers.py rename to vultron/core/behaviors/helpers.py index 7450a14f..61520c3d 100644 --- a/vultron/behaviors/helpers.py +++ b/vultron/core/behaviors/helpers.py @@ -30,13 +30,33 @@ import py_trees from py_trees.common import Status +from pydantic import BaseModel -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.api.v2.datalayer.db_record import Record +from vultron.core.ports.datalayer import DataLayer, StorableRecord logger = logging.getLogger(__name__) +def save_to_datalayer(dl: DataLayer, obj: BaseModel) -> None: + """Persist an AS2-like Pydantic model to the DataLayer. + + Constructs a ``StorableRecord`` from the object's ``as_id``, + ``as_type``, and serialised data, then calls ``dl.update()``. + This helper lets core BT nodes persist objects without importing + the adapter-layer ``Record`` / ``object_to_record`` utilities. + + Args: + dl: The DataLayer instance to persist to. + obj: A Pydantic model with ``as_id`` and ``as_type`` attributes. + """ + record = StorableRecord( + id_=getattr(obj, "as_id"), + type_=str(getattr(obj, "as_type", type(obj).__name__)), + data_=obj.model_dump(mode="json"), + ) + dl.update(record.id_, record) + + class DataLayerCondition(py_trees.behaviour.Behaviour): """ Base class for BT condition nodes that check state from DataLayer. @@ -281,27 +301,25 @@ def update(self) -> Status: self.logger.error(self.feedback_message) return Status.FAILURE - # Assume current_dict is the raw record dict {id_, type_, data_} - # Apply updates to the data_ field + # Build an updated StorableRecord without importing the adapter-layer Record. if "data_" in current_dict: - # This is a Record dict updated_data = {**current_dict["data_"], **self.updates} - updated_record = Record( + storable = StorableRecord( id_=current_dict["id_"], type_=current_dict["type_"], data_=updated_data, ) else: - # This is just data fields directly updated_data = {**current_dict, **self.updates} - # Extract type from data if available record_type = updated_data.get("as_type", "Object") - updated_record = Record( - id_=self.object_id, type_=record_type, data_=updated_data + storable = StorableRecord( + id_=self.object_id, + type_=record_type, + data_=updated_data, ) # Persist to DataLayer - self.datalayer.update(self.object_id, updated_record) + self.datalayer.update(self.object_id, storable) self.feedback_message = ( f"Updated {self.object_id} with {len(self.updates)} fields" @@ -326,7 +344,7 @@ class CreateObject(DataLayerAction): def __init__( self, table: str, - object_data: Record, + object_data: dict, name: str | None = None, ): """ @@ -334,7 +352,7 @@ def __init__( Args: table: DataLayer table name to create object in - object_data: Record data for new object (must include 'as_id' field) + object_data: Data dict for new object (must include 'as_id' field) name: Optional custom name (defaults to "CreateObject_{table}") """ display_name = name or f"CreateObject_{table}" @@ -366,13 +384,15 @@ def update(self) -> Status: object_type = self.object_data.get("as_type", self.table) object_id = self.object_data["as_id"] - # Create Record wrapper - record = Record( - id_=object_id, type_=object_type, data_=self.object_data + # Build a typed StorableRecord and pass it to the DataLayer + storable = StorableRecord( + id_=object_id, + type_=object_type, + data_=self.object_data, ) # Create object in DataLayer - self.datalayer.create(record) + self.datalayer.create(storable) self.feedback_message = f"Created {self.table}/{object_id}" self.logger.info(self.feedback_message) diff --git a/vultron/behaviors/report/__init__.py b/vultron/core/behaviors/report/__init__.py similarity index 100% rename from vultron/behaviors/report/__init__.py rename to vultron/core/behaviors/report/__init__.py diff --git a/vultron/behaviors/report/nodes.py b/vultron/core/behaviors/report/nodes.py similarity index 93% rename from vultron/behaviors/report/nodes.py rename to vultron/core/behaviors/report/nodes.py index 952cf71e..431d08e7 100644 --- a/vultron/behaviors/report/nodes.py +++ b/vultron/core/behaviors/report/nodes.py @@ -29,18 +29,24 @@ import py_trees from py_trees.common import Status -from vultron.api.v2.data.status import ( +from vultron.core.models.status import ( OfferStatus, ReportStatus, get_status_layer, set_status, ) -from vultron.api.v2.datalayer.db_record import object_to_record -from vultron.as_vocab.activities.case import CreateCase as as_CreateCase -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.behaviors.helpers import DataLayerAction, DataLayerCondition +from vultron.core.models.vultron_types import ( + VultronCase, + VultronCreateCaseActivity, + VultronParticipantStatus, +) +from vultron.core.behaviors.helpers import ( + DataLayerAction, + DataLayerCondition, + save_to_datalayer, +) from vultron.bt.report_management.states import RM -from vultron.enums import OfferStatusEnum +from vultron.core.models.status import OfferStatusEnum logger = logging.getLogger(__name__) @@ -325,7 +331,7 @@ class CreateCaseNode(DataLayerAction): This node implements case creation from the validate_report handler. - Note: Named CreateCaseNode (not CreateCase) to avoid conflict with + Note: Named CreateCaseNode (not CreateCaseActivity) to avoid conflict with as_CreateCase activity class. """ @@ -359,10 +365,15 @@ def update(self) -> Status: self.report_id, raise_on_missing=True ) - # Create VulnerabilityCase - case = VulnerabilityCase( + # Create VulnerabilityCase domain object + report_id_ref = ( + report_obj.as_id + if hasattr(report_obj, "as_id") + else self.report_id + ) + case = VultronCase( name=f"Case for Report {self.report_id}", - vulnerability_reports=[report_obj], + vulnerability_reports=[report_id_ref], attributed_to=self.actor_id, ) @@ -393,9 +404,9 @@ def update(self) -> Status: class CreateCaseActivity(DataLayerAction): """ - Create CreateCase activity for case creation notification. + Create CreateCaseActivity activity for case creation notification. - Generates a CreateCase activity to notify relevant actors about the new case. + Generates a CreateCaseActivity activity to notify relevant actors about the new case. Collects addressees from actor, report.attributed_to, and offer.to fields. This node implements activity generation from the validate_report handler. @@ -424,7 +435,7 @@ def setup(self, **kwargs: Any) -> None: def update(self) -> Status: """ - Create CreateCase activity and persist to DataLayer. + Create CreateCaseActivity activity and persist to DataLayer. Returns: SUCCESS if activity created, FAILURE on error @@ -471,20 +482,20 @@ def update(self) -> Status: f"{self.name}: Notifying addressees: {addressees}" ) - # Create CreateCase activity - create_case_activity = as_CreateCase( - actor=self.actor_id, object=case_id, to=addressees + # Create CreateCaseActivity activity domain object + create_case_activity = VultronCreateCaseActivity( + actor=self.actor_id, object=case_id ) # Store activity in DataLayer try: self.datalayer.create(create_case_activity) self.logger.info( - f"{self.name}: Created CreateCase activity: {create_case_activity.as_id}" + f"{self.name}: Created CreateCaseActivity activity: {create_case_activity.as_id}" ) except ValueError as e: self.logger.warning( - f"{self.name}: CreateCase activity {create_case_activity.as_id} already exists: {e}" + f"{self.name}: CreateCaseActivity activity {create_case_activity.as_id} already exists: {e}" ) # Store activity_id in blackboard for UpdateActorOutbox node @@ -497,7 +508,7 @@ def update(self) -> Status: except Exception as e: self.logger.error( - f"{self.name}: Error creating CreateCase activity: {e}" + f"{self.name}: Error creating CreateCaseActivity activity: {e}" ) return Status.FAILURE @@ -506,7 +517,7 @@ class UpdateActorOutbox(DataLayerAction): """ Update actor's outbox with new activity. - Appends the CreateCase activity ID to the actor's outbox.items list and + Appends the CreateCaseActivity activity ID to the actor's outbox.items list and persists the updated actor to the DataLayer. This node implements the outbox update from the validate_report handler. @@ -572,7 +583,7 @@ def update(self) -> Status: ) # Persist updated actor - self.datalayer.update(actor_obj.as_id, object_to_record(actor_obj)) + save_to_datalayer(self.datalayer, actor_obj) self.logger.info( f"{self.name}: Updated actor {self.actor_id} in DataLayer" ) @@ -741,9 +752,6 @@ def _find_and_update_participant_rm( Returns SUCCESS on success, FAILURE on error or missing participant. """ - from vultron.api.v2.datalayer.db_record import object_to_record - from vultron.as_vocab.objects.case_status import ParticipantStatus - try: case_obj = datalayer.read(case_id, raise_on_missing=True) @@ -765,13 +773,13 @@ def _find_and_update_participant_rm( f"{new_rm_state} in case {case_id} (idempotent)" ) return Status.SUCCESS - new_status = ParticipantStatus( - actor=actor_id, + new_status = VultronParticipantStatus( + attributed_to=actor_id, context=case_id, rm_state=new_rm_state, ) participant.participant_statuses.append(new_status) - datalayer.update(case_obj.as_id, object_to_record(case_obj)) + save_to_datalayer(datalayer, case_obj) logger.info( f"Set participant {actor_id} RM state to {new_rm_state} in case {case_id}" ) @@ -795,7 +803,7 @@ class TransitionParticipantRMtoAccepted(DataLayerAction): new ParticipantStatus with rm_state=RM.ACCEPTED, and persists the updated case to the DataLayer. - Called when an actor engages a case (receives RmEngageCase / + Called when an actor engages a case (receives RmEngageCaseActivity / Join(VulnerabilityCase)). """ @@ -842,7 +850,7 @@ class TransitionParticipantRMtoDeferred(DataLayerAction): new ParticipantStatus with rm_state=RM.DEFERRED, and persists the updated case to the DataLayer. - Called when an actor defers a case (receives RmDeferCase / + Called when an actor defers a case (receives RmDeferCaseActivity / Ignore(VulnerabilityCase)). """ @@ -889,7 +897,7 @@ class EvaluateCasePriority(DataLayerCondition): Future: Plug in SSVC or other priority framework via PrioritizationPolicy. This node is used when the local actor is DECIDING whether to engage or - defer (i.e., generating an outgoing RmEngageCase or RmDeferCase message), + defer (i.e., generating an outgoing RmEngageCaseActivity or RmDeferCaseActivity message), as opposed to the receive-side nodes above which record a decision already made by the sending actor. diff --git a/vultron/behaviors/report/policy.py b/vultron/core/behaviors/report/policy.py similarity index 74% rename from vultron/behaviors/report/policy.py rename to vultron/core/behaviors/report/policy.py index 5ebdf2a7..ae9df57b 100644 --- a/vultron/behaviors/report/policy.py +++ b/vultron/core/behaviors/report/policy.py @@ -33,8 +33,7 @@ import logging -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.core.models.vultron_types import VultronCase, VultronReport logger = logging.getLogger(__name__) @@ -49,7 +48,7 @@ class ValidationPolicy: This class defines the interface for pluggable policy implementations. """ - def is_credible(self, report: VulnerabilityReport) -> bool: + def is_credible(self, report: VultronReport) -> bool: """ Evaluate whether report source is credible. @@ -57,20 +56,14 @@ def is_credible(self, report: VulnerabilityReport) -> bool: the report appears legitimate (not spam, not malicious). Args: - report: VulnerabilityReport object to evaluate + report: report domain object to evaluate Returns: True if report source is credible, False otherwise - - Example: - >>> policy = ValidationPolicy() - >>> report = VulnerabilityReport(name="CVE-2024-001", content="...") - >>> policy.is_credible(report) - NotImplementedError """ raise NotImplementedError("Subclasses must implement is_credible()") - def is_valid(self, report: VulnerabilityReport) -> bool: + def is_valid(self, report: VultronReport) -> bool: """ Evaluate whether report content is technically valid. @@ -79,16 +72,10 @@ def is_valid(self, report: VulnerabilityReport) -> bool: assessment. Args: - report: VulnerabilityReport object to evaluate + report: report domain object to evaluate Returns: True if report content is valid, False otherwise - - Example: - >>> policy = ValidationPolicy() - >>> report = VulnerabilityReport(name="CVE-2024-001", content="...") - >>> policy.is_valid(report) - NotImplementedError """ raise NotImplementedError("Subclasses must implement is_valid()") @@ -111,29 +98,16 @@ class AlwaysAcceptPolicy(ValidationPolicy): - Metadata-based filtering - Integration with external validation services - Reputation-based scoring - - Example: - >>> from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport - >>> policy = AlwaysAcceptPolicy() - >>> report = VulnerabilityReport( - ... as_id="https://example.org/reports/CVE-2024-001", - ... name="CVE-2024-001", - ... content="Buffer overflow in parse_input()" - ... ) - >>> policy.is_credible(report) - True - >>> policy.is_valid(report) - True """ - def is_credible(self, report: VulnerabilityReport) -> bool: + def is_credible(self, report: VultronReport) -> bool: """ Accept report as credible (always returns True). Logs acceptance decision at INFO level for observability. Args: - report: VulnerabilityReport object to evaluate + report: report domain object to evaluate Returns: True (always accepts) @@ -143,14 +117,14 @@ def is_credible(self, report: VulnerabilityReport) -> bool: ) return True - def is_valid(self, report: VulnerabilityReport) -> bool: + def is_valid(self, report: VultronReport) -> bool: """ Accept report as valid (always returns True). Logs acceptance decision at INFO level for observability. Args: - report: VulnerabilityReport object to evaluate + report: report domain object to evaluate Returns: True (always accepts) @@ -173,12 +147,12 @@ class PrioritizationPolicy: PROTO-05-001 for the deferral policy on SSVC integration. """ - def should_engage(self, case: VulnerabilityCase) -> bool: + def should_engage(self, case: VultronCase) -> bool: """ Evaluate whether the case should be engaged (accepted for active work). Args: - case: VulnerabilityCase to evaluate + case: case domain object to evaluate Returns: True to engage (RM.ACCEPTED), False to defer (RM.DEFERRED) @@ -194,20 +168,14 @@ class AlwaysPrioritizePolicy(PrioritizationPolicy): case. Suitable for prototype and trusted-coordinator scenarios. Future: Replace with SSVC-based evaluation (see PROTO-05-001). - - Example: - >>> policy = AlwaysPrioritizePolicy() - >>> case = VulnerabilityCase(name="Test Case") - >>> policy.should_engage(case) - True """ - def should_engage(self, case: VulnerabilityCase) -> bool: + def should_engage(self, case: VultronCase) -> bool: """ Always engage the case (returns True). Args: - case: VulnerabilityCase to evaluate (unused in this stub) + case: case domain object to evaluate (unused in this stub) Returns: True (always engages) diff --git a/vultron/behaviors/report/prioritize_tree.py b/vultron/core/behaviors/report/prioritize_tree.py similarity index 91% rename from vultron/behaviors/report/prioritize_tree.py rename to vultron/core/behaviors/report/prioritize_tree.py index a6617bcf..030ddbc8 100644 --- a/vultron/behaviors/report/prioritize_tree.py +++ b/vultron/core/behaviors/report/prioritize_tree.py @@ -17,8 +17,8 @@ Case prioritization behavior tree composition. This module composes the engage_case and defer_case workflows as behavior -trees. These handle the receive-side of RmEngageCase (Join(VulnerabilityCase)) -and RmDeferCase (Ignore(VulnerabilityCase)) activities. +trees. These handle the receive-side of RmEngageCaseActivity (Join(VulnerabilityCase)) +and RmDeferCaseActivity (Ignore(VulnerabilityCase)) activities. Background: RM is a participant-specific state machine. Each CaseParticipant wraps an Actor within a case and carries its own RM state via @@ -46,7 +46,7 @@ import py_trees -from vultron.behaviors.report.nodes import ( +from vultron.core.behaviors.report.nodes import ( CheckParticipantExists, TransitionParticipantRMtoAccepted, TransitionParticipantRMtoDeferred, @@ -62,7 +62,7 @@ def create_engage_case_tree( """ Create behavior tree for the engage_case workflow. - Handles receipt of RmEngageCase (Join(VulnerabilityCase)): the sending + Handles receipt of RmEngageCaseActivity (Join(VulnerabilityCase)): the sending actor has decided to engage the case, so we record their RM state transition to ACCEPTED in their CaseParticipant.participant_status. @@ -95,7 +95,7 @@ def create_defer_case_tree( """ Create behavior tree for the defer_case workflow. - Handles receipt of RmDeferCase (Ignore(VulnerabilityCase)): the sending + Handles receipt of RmDeferCaseActivity (Ignore(VulnerabilityCase)): the sending actor has decided to defer the case, so we record their RM state transition to DEFERRED in their CaseParticipant.participant_status. diff --git a/vultron/behaviors/report/validate_tree.py b/vultron/core/behaviors/report/validate_tree.py similarity index 97% rename from vultron/behaviors/report/validate_tree.py rename to vultron/core/behaviors/report/validate_tree.py index e2aeb538..e63fe776 100644 --- a/vultron/behaviors/report/validate_tree.py +++ b/vultron/core/behaviors/report/validate_tree.py @@ -32,7 +32,7 @@ └─ ValidationActions (Sequence) ├─ TransitionRMtoValid # Update statuses ├─ CreateCaseNode # Create case object - ├─ CreateCaseActivity # Generate CreateCase activity + ├─ CreateCaseActivity # Generate CreateCaseActivity activity └─ UpdateActorOutbox # Add to outbox Phase 1 simplifications: @@ -52,7 +52,7 @@ import py_trees -from vultron.behaviors.report.nodes import ( +from vultron.core.behaviors.report.nodes import ( CheckRMStateReceivedOrInvalid, CheckRMStateValid, CreateCaseActivity, @@ -89,7 +89,7 @@ def create_validate_report_tree( ... report_id="https://example.org/reports/CVE-2024-001", ... offer_id="https://example.org/activities/offer-123" ... ) - >>> from vultron.behaviors.bridge import BTBridge + >>> from vultron.core.behaviors.bridge import BTBridge >>> bridge = BTBridge() >>> result = bridge.execute_with_setup( ... tree, diff --git a/vultron/core/models/__init__.py b/vultron/core/models/__init__.py new file mode 100644 index 00000000..8a33d18a --- /dev/null +++ b/vultron/core/models/__init__.py @@ -0,0 +1 @@ +"""Domain model definitions for the Vultron Protocol.""" diff --git a/vultron/core/models/_helpers.py b/vultron/core/models/_helpers.py new file mode 100644 index 00000000..c5801e64 --- /dev/null +++ b/vultron/core/models/_helpers.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Shared helper factories for core domain model types.""" + +import uuid +from datetime import datetime, timezone + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _new_urn() -> str: + return f"urn:uuid:{uuid.uuid4()}" diff --git a/vultron/core/models/activity.py b/vultron/core/models/activity.py new file mode 100644 index 00000000..3b1a2415 --- /dev/null +++ b/vultron/core/models/activity.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representations of AS2 activity types used in the core layer.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _new_urn + + +class VultronActivity(BaseModel): + """Domain representation of an AS2 activity for DataLayer storage. + + ``as_type`` is required and must be set to the actual activity type + (e.g. ``"Offer"``, ``"Accept"``, ``"Invite"``, ``"Leave"``, ``"Read"``). + + Field names match the wire-layer ``as_Activity`` internal names so that + a stored ``VultronActivity`` can be round-tripped through + ``record_to_object`` and deserialized as the appropriate AS2 activity + subclass. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str + actor: str | None = None + as_object: str | None = None + target: str | None = None + origin: str | None = None + context: str | None = None + in_reply_to: str | None = None + + +class VultronOffer(BaseModel): + """Domain representation of an Offer activity. + + Mirrors the essential fields of ``as_Offer``. + ``as_type`` is ``"Offer"`` to match the wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "Offer" + actor: str | None = None + object: Any | None = None + to: Any | None = None + target: Any | None = None + + +class VultronAccept(BaseModel): + """Domain representation of an Accept activity. + + Mirrors the essential fields of ``as_Accept``. + ``as_type`` is ``"Accept"`` to match the wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "Accept" + actor: str | None = None + object: Any | None = None + + +class VultronCreateCaseActivity(BaseModel): + """Domain representation of a Create(Case) activity. + + Mirrors the essential fields of ``as_CreateCase``. + ``as_type`` is ``"Create"`` to match the wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "Create" + actor: str | None = None + object: str | None = None diff --git a/vultron/core/models/case.py b/vultron/core/models/case.py new file mode 100644 index 00000000..86b2bedd --- /dev/null +++ b/vultron/core/models/case.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a vulnerability case.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _new_urn +from vultron.core.models.case_event import VultronCaseEvent +from vultron.core.models.case_status import VultronCaseStatus +from vultron.core.models.participant import VultronParticipant + + +class VultronCase(BaseModel): + """Domain representation of a vulnerability case. + + Mirrors the Vultron-specific fields of ``VulnerabilityCase``. Cross- + references to related objects are stored as ``str`` ID values, which are + valid members of the corresponding wire-type union fields (e.g. + ``VulnerabilityReportRef``, ``CaseParticipantRef``), ensuring DataLayer + round-trip compatibility. + + ``as_type`` is ``"VulnerabilityCase"`` so that TinyDB stores this in the + same table as wire-created cases and ``record_to_object`` can round-trip + it via the wire vocabulary registry. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "VulnerabilityCase" + name: str | None = None + summary: str | None = None + content: str | None = None + url: str | None = None + context: Any | None = None + attributed_to: Any | None = None + published: datetime | None = None + updated: datetime | None = None + case_participants: list[str | VultronParticipant] = Field( + default_factory=list + ) + actor_participant_index: dict[str, str] = Field(default_factory=dict) + vulnerability_reports: list[str] = Field(default_factory=list) + case_statuses: list[str | VultronCaseStatus] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + active_embargo: str | None = None + proposed_embargoes: list[str] = Field(default_factory=list) + case_activity: list[str] = Field(default_factory=list) + events: list[VultronCaseEvent] = Field(default_factory=list) + parent_cases: list[str] = Field(default_factory=list) + child_cases: list[str] = Field(default_factory=list) + sibling_cases: list[str] = Field(default_factory=list) diff --git a/vultron/core/models/case_actor.py b/vultron/core/models/case_actor.py new file mode 100644 index 00000000..6dcb039e --- /dev/null +++ b/vultron/core/models/case_actor.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representations for CaseActor and its outbox.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _new_urn + + +class VultronOutbox(BaseModel): + """Minimal outbox representation for domain actor types.""" + + items: list[str] = Field(default_factory=list) + + +class VultronCaseActor(BaseModel): + """Domain representation of a CaseActor service. + + Mirrors the Vultron-specific fields of ``CaseActor`` (which inherits + ``as_Service``). The ``outbox`` field carries the actor's outgoing + activity IDs and is required so that ``UpdateActorOutbox`` can append + to it via ``save_to_datalayer``. + ``as_type`` is ``"Service"`` to match ``CaseActor``'s wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "Service" + name: str | None = None + attributed_to: Any | None = None + context: Any | None = None + outbox: VultronOutbox = Field(default_factory=VultronOutbox) diff --git a/vultron/core/models/case_event.py b/vultron/core/models/case_event.py new file mode 100644 index 00000000..f6d0600e --- /dev/null +++ b/vultron/core/models/case_event.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a case event log entry.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _now_utc + + +class VultronCaseEvent(BaseModel): + """Domain representation of a case event log entry. + + Mirrors ``CaseEvent`` from ``vultron.wire.as2.vocab.objects.case_event`` + using identical field names for DataLayer round-trip compatibility. + """ + + object_id: str + event_type: str + received_at: datetime = Field(default_factory=_now_utc) diff --git a/vultron/core/models/case_status.py b/vultron/core/models/case_status.py new file mode 100644 index 00000000..79e5c49e --- /dev/null +++ b/vultron/core/models/case_status.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a case status snapshot.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_serializer + +from vultron.bt.embargo_management.states import EM +from vultron.case_states.states import CS_pxa +from vultron.core.models._helpers import _new_urn + + +class VultronCaseStatus(BaseModel): + """Domain representation of a case status snapshot. + + Mirrors the Vultron-specific fields of ``CaseStatus``. + ``as_type`` is ``"CaseStatus"`` to match the wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "CaseStatus" + name: str | None = None + context: str | None = None + attributed_to: Any | None = None + em_state: EM = EM.EMBARGO_MANAGEMENT_NONE + pxa_state: CS_pxa = CS_pxa.pxa + + @field_serializer("pxa_state") + def _serialize_pxa_state(self, v: CS_pxa) -> str: + return v.name diff --git a/vultron/core/models/embargo_event.py b/vultron/core/models/embargo_event.py new file mode 100644 index 00000000..eba47654 --- /dev/null +++ b/vultron/core/models/embargo_event.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of an EmbargoEvent.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _new_urn + + +class VultronEmbargoEvent(BaseModel): + """Domain representation of an EmbargoEvent. + + ``as_type`` is ``"Event"`` to match the wire value (EmbargoEvent inherits + as_Event and does not override as_type). + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "Event" + name: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + published: datetime | None = None + updated: datetime | None = None + context: str | None = None diff --git a/vultron/core/models/enums.py b/vultron/core/models/enums.py new file mode 100644 index 00000000..1bbafd96 --- /dev/null +++ b/vultron/core/models/enums.py @@ -0,0 +1,29 @@ +"""Core domain enumeration definitions for the Vultron Protocol.""" + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +from enum import StrEnum + + +class VultronObjectType(StrEnum): + """Enumeration of Vultron-specific domain object types.""" + + VULNERABILITY_CASE = "VulnerabilityCase" + VULNERABILITY_REPORT = "VulnerabilityReport" + VULNERABILITY_RECORD = "VulnerabilityRecord" + CASE_REFERENCE = "CaseReference" + EMBARGO_POLICY = "EmbargoPolicy" + CASE_PARTICIPANT = "CaseParticipant" + CASE_STATUS = "CaseStatus" + PARTICIPANT_STATUS = "ParticipantStatus" diff --git a/vultron/core/models/events/__init__.py b/vultron/core/models/events/__init__.py new file mode 100644 index 00000000..f7302ac7 --- /dev/null +++ b/vultron/core/models/events/__init__.py @@ -0,0 +1,176 @@ +"""Domain event vocabulary for the Vultron Protocol. + +Defines the authoritative vocabulary of semantic intents that can occur +in the system, as understood by the domain layer. + +Public surface: +- MessageSemantics — enum of all recognised semantic types +- VultronEvent — base class for all per-semantic inbound domain events +- InboundPayload — backward-compat alias for VultronEvent +- EVENT_CLASS_MAP — mapping from MessageSemantics to its concrete event class +- Per-semantic *ReceivedEvent classes imported from category submodules +""" + +from vultron.core.models.events.base import ( + MessageSemantics, + NonEmptyString, + OptionalNonEmptyString, + VultronEvent, +) +from vultron.core.models.events.actor import ( + AcceptCaseOwnershipTransferReceivedEvent, + AcceptInviteActorToCaseReceivedEvent, + AcceptSuggestActorToCaseReceivedEvent, + InviteActorToCaseReceivedEvent, + OfferCaseOwnershipTransferReceivedEvent, + RejectCaseOwnershipTransferReceivedEvent, + RejectInviteActorToCaseReceivedEvent, + RejectSuggestActorToCaseReceivedEvent, + SuggestActorToCaseReceivedEvent, +) +from vultron.core.models.events.case import ( + AddReportToCaseReceivedEvent, + CloseCaseReceivedEvent, + CreateCaseReceivedEvent, + DeferCaseReceivedEvent, + EngageCaseReceivedEvent, + UpdateCaseReceivedEvent, +) +from vultron.core.models.events.case_participant import ( + AddCaseParticipantToCaseReceivedEvent, + CreateCaseParticipantReceivedEvent, + RemoveCaseParticipantFromCaseReceivedEvent, +) +from vultron.core.models.events.embargo import ( + AcceptInviteToEmbargoOnCaseReceivedEvent, + AddEmbargoEventToCaseReceivedEvent, + AnnounceEmbargoEventToCaseReceivedEvent, + CreateEmbargoEventReceivedEvent, + InviteToEmbargoOnCaseReceivedEvent, + RejectInviteToEmbargoOnCaseReceivedEvent, + RemoveEmbargoEventFromCaseReceivedEvent, +) +from vultron.core.models.events.note import ( + AddNoteToCaseReceivedEvent, + CreateNoteReceivedEvent, + RemoveNoteFromCaseReceivedEvent, +) +from vultron.core.models.events.report import ( + AckReportReceivedEvent, + CloseReportReceivedEvent, + CreateReportReceivedEvent, + InvalidateReportReceivedEvent, + SubmitReportReceivedEvent, + ValidateReportReceivedEvent, +) +from vultron.core.models.events.status import ( + AddCaseStatusToCaseReceivedEvent, + AddParticipantStatusToParticipantReceivedEvent, + CreateCaseStatusReceivedEvent, + CreateParticipantStatusReceivedEvent, +) +from vultron.core.models.events.unknown import UnknownReceivedEvent + +# Backward-compat alias: code that imports InboundPayload continues to work. +# New code should prefer VultronEvent or the concrete per-semantic subclasses. +InboundPayload = VultronEvent + +# Maps each MessageSemantics value to its concrete VultronEvent subclass. +# Used by extract_intent() in the wire layer to construct the right domain event. +EVENT_CLASS_MAP: dict[MessageSemantics, type[VultronEvent]] = { + MessageSemantics.CREATE_REPORT: CreateReportReceivedEvent, + MessageSemantics.SUBMIT_REPORT: SubmitReportReceivedEvent, + MessageSemantics.VALIDATE_REPORT: ValidateReportReceivedEvent, + MessageSemantics.INVALIDATE_REPORT: InvalidateReportReceivedEvent, + MessageSemantics.ACK_REPORT: AckReportReceivedEvent, + MessageSemantics.CLOSE_REPORT: CloseReportReceivedEvent, + MessageSemantics.CREATE_CASE: CreateCaseReceivedEvent, + MessageSemantics.UPDATE_CASE: UpdateCaseReceivedEvent, + MessageSemantics.ENGAGE_CASE: EngageCaseReceivedEvent, + MessageSemantics.DEFER_CASE: DeferCaseReceivedEvent, + MessageSemantics.ADD_REPORT_TO_CASE: AddReportToCaseReceivedEvent, + MessageSemantics.SUGGEST_ACTOR_TO_CASE: SuggestActorToCaseReceivedEvent, + MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE: AcceptSuggestActorToCaseReceivedEvent, + MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE: RejectSuggestActorToCaseReceivedEvent, + MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER: OfferCaseOwnershipTransferReceivedEvent, + MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER: AcceptCaseOwnershipTransferReceivedEvent, + MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER: RejectCaseOwnershipTransferReceivedEvent, + MessageSemantics.INVITE_ACTOR_TO_CASE: InviteActorToCaseReceivedEvent, + MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE: AcceptInviteActorToCaseReceivedEvent, + MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE: RejectInviteActorToCaseReceivedEvent, + MessageSemantics.CREATE_EMBARGO_EVENT: CreateEmbargoEventReceivedEvent, + MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE: AddEmbargoEventToCaseReceivedEvent, + MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE: RemoveEmbargoEventFromCaseReceivedEvent, + MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE: AnnounceEmbargoEventToCaseReceivedEvent, + MessageSemantics.INVITE_TO_EMBARGO_ON_CASE: InviteToEmbargoOnCaseReceivedEvent, + MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE: AcceptInviteToEmbargoOnCaseReceivedEvent, + MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE: RejectInviteToEmbargoOnCaseReceivedEvent, + MessageSemantics.CLOSE_CASE: CloseCaseReceivedEvent, + MessageSemantics.CREATE_CASE_PARTICIPANT: CreateCaseParticipantReceivedEvent, + MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE: AddCaseParticipantToCaseReceivedEvent, + MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE: RemoveCaseParticipantFromCaseReceivedEvent, + MessageSemantics.CREATE_NOTE: CreateNoteReceivedEvent, + MessageSemantics.ADD_NOTE_TO_CASE: AddNoteToCaseReceivedEvent, + MessageSemantics.REMOVE_NOTE_FROM_CASE: RemoveNoteFromCaseReceivedEvent, + MessageSemantics.CREATE_CASE_STATUS: CreateCaseStatusReceivedEvent, + MessageSemantics.ADD_CASE_STATUS_TO_CASE: AddCaseStatusToCaseReceivedEvent, + MessageSemantics.CREATE_PARTICIPANT_STATUS: CreateParticipantStatusReceivedEvent, + MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT: AddParticipantStatusToParticipantReceivedEvent, + MessageSemantics.UNKNOWN: UnknownReceivedEvent, +} + +__all__ = [ + "MessageSemantics", + "NonEmptyString", + "OptionalNonEmptyString", + "VultronEvent", + "InboundPayload", + "EVENT_CLASS_MAP", + # report + "CreateReportReceivedEvent", + "SubmitReportReceivedEvent", + "ValidateReportReceivedEvent", + "InvalidateReportReceivedEvent", + "AckReportReceivedEvent", + "CloseReportReceivedEvent", + # case + "CreateCaseReceivedEvent", + "UpdateCaseReceivedEvent", + "EngageCaseReceivedEvent", + "DeferCaseReceivedEvent", + "AddReportToCaseReceivedEvent", + "CloseCaseReceivedEvent", + # actor + "SuggestActorToCaseReceivedEvent", + "AcceptSuggestActorToCaseReceivedEvent", + "RejectSuggestActorToCaseReceivedEvent", + "OfferCaseOwnershipTransferReceivedEvent", + "AcceptCaseOwnershipTransferReceivedEvent", + "RejectCaseOwnershipTransferReceivedEvent", + "InviteActorToCaseReceivedEvent", + "AcceptInviteActorToCaseReceivedEvent", + "RejectInviteActorToCaseReceivedEvent", + # case_participant + "CreateCaseParticipantReceivedEvent", + "AddCaseParticipantToCaseReceivedEvent", + "RemoveCaseParticipantFromCaseReceivedEvent", + # embargo + "CreateEmbargoEventReceivedEvent", + "AddEmbargoEventToCaseReceivedEvent", + "RemoveEmbargoEventFromCaseReceivedEvent", + "AnnounceEmbargoEventToCaseReceivedEvent", + "InviteToEmbargoOnCaseReceivedEvent", + "AcceptInviteToEmbargoOnCaseReceivedEvent", + "RejectInviteToEmbargoOnCaseReceivedEvent", + # note + "CreateNoteReceivedEvent", + "AddNoteToCaseReceivedEvent", + "RemoveNoteFromCaseReceivedEvent", + # status + "CreateCaseStatusReceivedEvent", + "AddCaseStatusToCaseReceivedEvent", + "CreateParticipantStatusReceivedEvent", + "AddParticipantStatusToParticipantReceivedEvent", + # unknown + "UnknownReceivedEvent", +] diff --git a/vultron/core/models/events/actor.py b/vultron/core/models/events/actor.py new file mode 100644 index 00000000..bb177c33 --- /dev/null +++ b/vultron/core/models/events/actor.py @@ -0,0 +1,85 @@ +"""Per-semantic inbound domain event types for actor / case-membership activities. + +Covers suggest-actor, ownership-transfer, and invite-actor-to-case semantics. +""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import VultronActivity + + +class SuggestActorToCaseReceivedEvent(VultronEvent): + """Actor offered another actor as a participant in a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.SUGGEST_ACTOR_TO_CASE] = ( + MessageSemantics.SUGGEST_ACTOR_TO_CASE + ) + activity: VultronActivity | None = None + + +class AcceptSuggestActorToCaseReceivedEvent(VultronEvent): + """Actor accepted a suggestion to add another actor to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE] = ( + MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE + ) + activity: VultronActivity | None = None + + +class RejectSuggestActorToCaseReceivedEvent(VultronEvent): + """Actor rejected a suggestion to add another actor to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE] = ( + MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE + ) + + +class OfferCaseOwnershipTransferReceivedEvent(VultronEvent): + """Actor offered ownership of a VulnerabilityCase to another actor.""" + + semantic_type: Literal[MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER] = ( + MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER + ) + activity: VultronActivity | None = None + + +class AcceptCaseOwnershipTransferReceivedEvent(VultronEvent): + """Actor accepted an offer to take ownership of a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER] = ( + MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER + ) + + +class RejectCaseOwnershipTransferReceivedEvent(VultronEvent): + """Actor rejected an offer to take ownership of a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER] = ( + MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER + ) + + +class InviteActorToCaseReceivedEvent(VultronEvent): + """Actor invited another actor to join a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.INVITE_ACTOR_TO_CASE] = ( + MessageSemantics.INVITE_ACTOR_TO_CASE + ) + activity: VultronActivity | None = None + + +class AcceptInviteActorToCaseReceivedEvent(VultronEvent): + """Actor accepted an invitation to join a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE] = ( + MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE + ) + + +class RejectInviteActorToCaseReceivedEvent(VultronEvent): + """Actor rejected an invitation to join a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE] = ( + MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE + ) diff --git a/vultron/core/models/events/base.py b/vultron/core/models/events/base.py new file mode 100644 index 00000000..f2f8fafa --- /dev/null +++ b/vultron/core/models/events/base.py @@ -0,0 +1,118 @@ +"""Base domain event types for the Vultron Protocol. + +Defines the core vocabulary of semantic intents (MessageSemantics) and the +VultronEvent base class that all per-semantic inbound domain event types +inherit from. +""" + +from enum import auto, StrEnum +from typing import Annotated, Optional + +from pydantic import AfterValidator, BaseModel + + +def _non_empty(v: str) -> str: + if not v.strip(): + raise ValueError("must be a non-empty string") + return v + + +NonEmptyString = Annotated[str, AfterValidator(_non_empty)] +OptionalNonEmptyString = Optional[NonEmptyString] + + +class MessageSemantics(StrEnum): + """Defines high-level semantics for certain activity patterns that may be relevant for behavior dispatching.""" + + CREATE_REPORT = auto() + SUBMIT_REPORT = auto() + VALIDATE_REPORT = auto() + INVALIDATE_REPORT = auto() + ACK_REPORT = auto() + CLOSE_REPORT = auto() + + CREATE_CASE = auto() + UPDATE_CASE = auto() + ENGAGE_CASE = auto() + DEFER_CASE = auto() + ADD_REPORT_TO_CASE = auto() + + SUGGEST_ACTOR_TO_CASE = auto() + ACCEPT_SUGGEST_ACTOR_TO_CASE = auto() + REJECT_SUGGEST_ACTOR_TO_CASE = auto() + OFFER_CASE_OWNERSHIP_TRANSFER = auto() + ACCEPT_CASE_OWNERSHIP_TRANSFER = auto() + REJECT_CASE_OWNERSHIP_TRANSFER = auto() + + INVITE_ACTOR_TO_CASE = auto() + ACCEPT_INVITE_ACTOR_TO_CASE = auto() + REJECT_INVITE_ACTOR_TO_CASE = auto() + + CREATE_EMBARGO_EVENT = auto() + ADD_EMBARGO_EVENT_TO_CASE = auto() + REMOVE_EMBARGO_EVENT_FROM_CASE = auto() + ANNOUNCE_EMBARGO_EVENT_TO_CASE = auto() + INVITE_TO_EMBARGO_ON_CASE = auto() + ACCEPT_INVITE_TO_EMBARGO_ON_CASE = auto() + REJECT_INVITE_TO_EMBARGO_ON_CASE = auto() + + CLOSE_CASE = auto() + + CREATE_CASE_PARTICIPANT = auto() + ADD_CASE_PARTICIPANT_TO_CASE = auto() + REMOVE_CASE_PARTICIPANT_FROM_CASE = auto() + + CREATE_NOTE = auto() + ADD_NOTE_TO_CASE = auto() + REMOVE_NOTE_FROM_CASE = auto() + + CREATE_CASE_STATUS = auto() + ADD_CASE_STATUS_TO_CASE = auto() + + CREATE_PARTICIPANT_STATUS = auto() + ADD_PARTICIPANT_STATUS_TO_PARTICIPANT = auto() + + # reserved for activities that don't fit any of the above semantics, but we want to be able to dispatch on them anyway + UNKNOWN = auto() + + +class VultronEvent(BaseModel): + """Base domain event produced from an inbound wire-format activity. + + Produced by extract_intent() in the wire layer before dispatch. + All fields are plain domain types (strings); no AS2 wire types are present. + + Concrete per-semantic subclasses set semantic_type as a Literal to enable + type-safe handler dispatch and Pydantic discriminated-union reconstruction. + """ + + semantic_type: MessageSemantics + activity_id: NonEmptyString + activity_type: OptionalNonEmptyString = None + actor_id: NonEmptyString + + object_id: OptionalNonEmptyString = None + object_type: OptionalNonEmptyString = None + + target_id: OptionalNonEmptyString = None + target_type: OptionalNonEmptyString = None + + context_id: OptionalNonEmptyString = None + context_type: OptionalNonEmptyString = None + + origin_id: OptionalNonEmptyString = None + origin_type: OptionalNonEmptyString = None + + in_reply_to: OptionalNonEmptyString = None + + # Nested fields: activity.as_object.as_object, .target, .context + inner_object_id: OptionalNonEmptyString = None + inner_object_type: OptionalNonEmptyString = None + inner_target_id: OptionalNonEmptyString = None + inner_target_type: OptionalNonEmptyString = None + inner_context_id: OptionalNonEmptyString = None + inner_context_type: OptionalNonEmptyString = None + + @property + def as_id(self) -> str: + return self.activity_id diff --git a/vultron/core/models/events/case.py b/vultron/core/models/events/case.py new file mode 100644 index 00000000..84fbdbed --- /dev/null +++ b/vultron/core/models/events/case.py @@ -0,0 +1,57 @@ +"""Per-semantic inbound domain event types for vulnerability case activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import VultronActivity, VultronCase + + +class CreateCaseReceivedEvent(VultronEvent): + """Actor created a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.CREATE_CASE] = ( + MessageSemantics.CREATE_CASE + ) + case: VultronCase | None = None + activity: VultronActivity | None = None + + +class UpdateCaseReceivedEvent(VultronEvent): + """Actor updated a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.UPDATE_CASE] = ( + MessageSemantics.UPDATE_CASE + ) + case: VultronCase | None = None + + +class EngageCaseReceivedEvent(VultronEvent): + """Actor joined (engaged) a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ENGAGE_CASE] = ( + MessageSemantics.ENGAGE_CASE + ) + + +class DeferCaseReceivedEvent(VultronEvent): + """Actor ignored (deferred) a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.DEFER_CASE] = ( + MessageSemantics.DEFER_CASE + ) + + +class AddReportToCaseReceivedEvent(VultronEvent): + """Actor added a VulnerabilityReport to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ADD_REPORT_TO_CASE] = ( + MessageSemantics.ADD_REPORT_TO_CASE + ) + + +class CloseCaseReceivedEvent(VultronEvent): + """Actor left (closed) a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.CLOSE_CASE] = ( + MessageSemantics.CLOSE_CASE + ) diff --git a/vultron/core/models/events/case_participant.py b/vultron/core/models/events/case_participant.py new file mode 100644 index 00000000..bdcb1e7b --- /dev/null +++ b/vultron/core/models/events/case_participant.py @@ -0,0 +1,31 @@ +"""Per-semantic inbound domain event types for case participant activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import VultronParticipant + + +class CreateCaseParticipantReceivedEvent(VultronEvent): + """Actor created a CaseParticipant record within a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.CREATE_CASE_PARTICIPANT] = ( + MessageSemantics.CREATE_CASE_PARTICIPANT + ) + participant: VultronParticipant | None = None + + +class AddCaseParticipantToCaseReceivedEvent(VultronEvent): + """Actor added a CaseParticipant to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE] = ( + MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE + ) + + +class RemoveCaseParticipantFromCaseReceivedEvent(VultronEvent): + """Actor removed a CaseParticipant from a VulnerabilityCase.""" + + semantic_type: Literal[ + MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE + ] = MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE diff --git a/vultron/core/models/events/embargo.py b/vultron/core/models/events/embargo.py new file mode 100644 index 00000000..c447e61b --- /dev/null +++ b/vultron/core/models/events/embargo.py @@ -0,0 +1,67 @@ +"""Per-semantic inbound domain event types for embargo activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import ( + VultronActivity, + VultronEmbargoEvent, +) + + +class CreateEmbargoEventReceivedEvent(VultronEvent): + """Actor created an EmbargoEvent.""" + + semantic_type: Literal[MessageSemantics.CREATE_EMBARGO_EVENT] = ( + MessageSemantics.CREATE_EMBARGO_EVENT + ) + embargo: VultronEmbargoEvent | None = None + + +class AddEmbargoEventToCaseReceivedEvent(VultronEvent): + """Actor added an EmbargoEvent to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE] = ( + MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE + ) + + +class RemoveEmbargoEventFromCaseReceivedEvent(VultronEvent): + """Actor removed an EmbargoEvent from a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE] = ( + MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE + ) + + +class AnnounceEmbargoEventToCaseReceivedEvent(VultronEvent): + """Actor announced an EmbargoEvent to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE] = ( + MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE + ) + + +class InviteToEmbargoOnCaseReceivedEvent(VultronEvent): + """Actor invited another actor to join an embargo on a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.INVITE_TO_EMBARGO_ON_CASE] = ( + MessageSemantics.INVITE_TO_EMBARGO_ON_CASE + ) + activity: VultronActivity | None = None + + +class AcceptInviteToEmbargoOnCaseReceivedEvent(VultronEvent): + """Actor accepted an invitation to join an embargo on a VulnerabilityCase.""" + + semantic_type: Literal[ + MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE + ] = MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE + + +class RejectInviteToEmbargoOnCaseReceivedEvent(VultronEvent): + """Actor rejected an invitation to join an embargo on a VulnerabilityCase.""" + + semantic_type: Literal[ + MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE + ] = MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE diff --git a/vultron/core/models/events/note.py b/vultron/core/models/events/note.py new file mode 100644 index 00000000..ffc166fb --- /dev/null +++ b/vultron/core/models/events/note.py @@ -0,0 +1,31 @@ +"""Per-semantic inbound domain event types for note activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import VultronNote + + +class CreateNoteReceivedEvent(VultronEvent): + """Actor created a Note.""" + + semantic_type: Literal[MessageSemantics.CREATE_NOTE] = ( + MessageSemantics.CREATE_NOTE + ) + note: VultronNote | None = None + + +class AddNoteToCaseReceivedEvent(VultronEvent): + """Actor added a Note to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ADD_NOTE_TO_CASE] = ( + MessageSemantics.ADD_NOTE_TO_CASE + ) + + +class RemoveNoteFromCaseReceivedEvent(VultronEvent): + """Actor removed a Note from a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.REMOVE_NOTE_FROM_CASE] = ( + MessageSemantics.REMOVE_NOTE_FROM_CASE + ) diff --git a/vultron/core/models/events/report.py b/vultron/core/models/events/report.py new file mode 100644 index 00000000..e6de013e --- /dev/null +++ b/vultron/core/models/events/report.py @@ -0,0 +1,62 @@ +"""Per-semantic inbound domain event types for vulnerability report activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import VultronActivity, VultronReport + + +class CreateReportReceivedEvent(VultronEvent): + """Actor created a VulnerabilityReport.""" + + semantic_type: Literal[MessageSemantics.CREATE_REPORT] = ( + MessageSemantics.CREATE_REPORT + ) + report: VultronReport | None = None + activity: VultronActivity | None = None + + +class SubmitReportReceivedEvent(VultronEvent): + """Actor submitted (offered) a VulnerabilityReport for validation.""" + + semantic_type: Literal[MessageSemantics.SUBMIT_REPORT] = ( + MessageSemantics.SUBMIT_REPORT + ) + report: VultronReport | None = None + activity: VultronActivity | None = None + + +class ValidateReportReceivedEvent(VultronEvent): + """Actor accepted an offer of a VulnerabilityReport, marking it as valid.""" + + semantic_type: Literal[MessageSemantics.VALIDATE_REPORT] = ( + MessageSemantics.VALIDATE_REPORT + ) + activity: VultronActivity | None = None + + +class InvalidateReportReceivedEvent(VultronEvent): + """Actor tentatively rejected an offer of a VulnerabilityReport.""" + + semantic_type: Literal[MessageSemantics.INVALIDATE_REPORT] = ( + MessageSemantics.INVALIDATE_REPORT + ) + activity: VultronActivity | None = None + + +class AckReportReceivedEvent(VultronEvent): + """Actor acknowledged (read) a VulnerabilityReport submission.""" + + semantic_type: Literal[MessageSemantics.ACK_REPORT] = ( + MessageSemantics.ACK_REPORT + ) + activity: VultronActivity | None = None + + +class CloseReportReceivedEvent(VultronEvent): + """Actor rejected an offer of a VulnerabilityReport, closing it.""" + + semantic_type: Literal[MessageSemantics.CLOSE_REPORT] = ( + MessageSemantics.CLOSE_REPORT + ) + activity: VultronActivity | None = None diff --git a/vultron/core/models/events/status.py b/vultron/core/models/events/status.py new file mode 100644 index 00000000..f156177e --- /dev/null +++ b/vultron/core/models/events/status.py @@ -0,0 +1,43 @@ +"""Per-semantic inbound domain event types for case and participant status activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent +from vultron.core.models.vultron_types import ( + VultronCaseStatus, + VultronParticipantStatus, +) + + +class CreateCaseStatusReceivedEvent(VultronEvent): + """Actor created a CaseStatus record for a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.CREATE_CASE_STATUS] = ( + MessageSemantics.CREATE_CASE_STATUS + ) + status: VultronCaseStatus | None = None + + +class AddCaseStatusToCaseReceivedEvent(VultronEvent): + """Actor added a CaseStatus to a VulnerabilityCase.""" + + semantic_type: Literal[MessageSemantics.ADD_CASE_STATUS_TO_CASE] = ( + MessageSemantics.ADD_CASE_STATUS_TO_CASE + ) + + +class CreateParticipantStatusReceivedEvent(VultronEvent): + """Actor created a ParticipantStatus record.""" + + semantic_type: Literal[MessageSemantics.CREATE_PARTICIPANT_STATUS] = ( + MessageSemantics.CREATE_PARTICIPANT_STATUS + ) + status: VultronParticipantStatus | None = None + + +class AddParticipantStatusToParticipantReceivedEvent(VultronEvent): + """Actor added a ParticipantStatus to a CaseParticipant.""" + + semantic_type: Literal[ + MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT + ] = MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT diff --git a/vultron/core/models/events/unknown.py b/vultron/core/models/events/unknown.py new file mode 100644 index 00000000..a9eb2a53 --- /dev/null +++ b/vultron/core/models/events/unknown.py @@ -0,0 +1,11 @@ +"""Per-semantic inbound domain event type for unrecognized activities.""" + +from typing import Literal + +from vultron.core.models.events.base import MessageSemantics, VultronEvent + + +class UnknownReceivedEvent(VultronEvent): + """Activity did not match any known semantic pattern.""" + + semantic_type: Literal[MessageSemantics.UNKNOWN] = MessageSemantics.UNKNOWN diff --git a/vultron/core/models/note.py b/vultron/core/models/note.py new file mode 100644 index 00000000..afca166b --- /dev/null +++ b/vultron/core/models/note.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a Note.""" + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _new_urn + + +class VultronNote(BaseModel): + """Domain representation of a Note. + + ``as_type`` is ``"Note"`` to match the wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "Note" + name: str | None = None + summary: str | None = None + content: str | None = None + url: str | None = None + attributed_to: str | None = None + context: str | None = None diff --git a/vultron/core/models/participant.py b/vultron/core/models/participant.py new file mode 100644 index 00000000..f8f5003f --- /dev/null +++ b/vultron/core/models/participant.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a case participant.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_serializer, field_validator + +from vultron.bt.roles.states import CVDRoles +from vultron.core.models._helpers import _new_urn +from vultron.core.models.participant_status import VultronParticipantStatus + + +class VultronParticipant(BaseModel): + """Domain representation of a case participant. + + Mirrors the Vultron-specific fields of ``CaseParticipant`` and its + subclasses (VendorParticipant, etc.). + ``as_type`` is ``"CaseParticipant"`` to match the wire value shared by all + ``CaseParticipant`` subclasses. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "CaseParticipant" + name: str | None = None + attributed_to: Any | None = None + context: str | None = None + case_roles: list[CVDRoles] = Field(default_factory=list) + participant_statuses: list[VultronParticipantStatus] = Field( + default_factory=list + ) + accepted_embargo_ids: list[str] = Field(default_factory=list) + participant_case_name: str | None = None + + @field_serializer("case_roles") + def _serialize_case_roles(self, value: list[CVDRoles]) -> list[str]: + return [role.name for role in value] + + @field_validator("case_roles", mode="before") + @classmethod + def _validate_case_roles(cls, value: list) -> list: + if isinstance(value, list) and value and isinstance(value[0], str): + return [CVDRoles[name] for name in value] + return value diff --git a/vultron/core/models/participant_status.py b/vultron/core/models/participant_status.py new file mode 100644 index 00000000..b1055e83 --- /dev/null +++ b/vultron/core/models/participant_status.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a participant RM-state status record.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_serializer + +from vultron.bt.report_management.states import RM +from vultron.case_states.states import CS_vfd +from vultron.core.models._helpers import _new_urn + + +class VultronParticipantStatus(BaseModel): + """Domain representation of a participant RM-state status record. + + Mirrors the Vultron-specific fields of ``ParticipantStatus``. + ``as_type`` is ``"ParticipantStatus"`` to match the wire value. + + ``context`` (case ID) is required, matching the wire type's constraint. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "ParticipantStatus" + name: str | None = None + context: str + attributed_to: Any | None = None + rm_state: RM = RM.START + vfd_state: CS_vfd = CS_vfd.vfd + case_engagement: bool = True + embargo_adherence: bool = True + tracking_id: str | None = None + case_status: str | None = None + + @field_serializer("vfd_state") + def _serialize_vfd_state(self, v: CS_vfd) -> str: + return v.name diff --git a/vultron/core/models/report.py b/vultron/core/models/report.py new file mode 100644 index 00000000..c54321e7 --- /dev/null +++ b/vultron/core/models/report.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Domain representation of a vulnerability report.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + +from vultron.core.models._helpers import _new_urn + + +class VultronReport(BaseModel): + """Domain representation of a vulnerability report. + + Mirrors the Vultron-specific fields of ``VulnerabilityReport``. + Policy implementations receive this type when evaluating credibility and + validity. + ``as_type`` is ``"VulnerabilityReport"`` to match the wire value. + """ + + as_id: str = Field(default_factory=_new_urn) + as_type: str = "VulnerabilityReport" + name: str | None = None + summary: str | None = None + content: Any | None = None + url: str | None = None + media_type: str | None = None + attributed_to: Any | None = None + context: Any | None = None + published: datetime | None = None + updated: datetime | None = None diff --git a/vultron/core/models/status.py b/vultron/core/models/status.py new file mode 100644 index 00000000..271a0ee7 --- /dev/null +++ b/vultron/core/models/status.py @@ -0,0 +1,93 @@ +"""In-memory status tracking models for Vultron Protocol domain objects. + +Tracks the transient offer/report status for objects currently being processed. +This module belongs to the core domain layer; adapter code that previously +defined these models in ``api/v2/data/status`` now re-exports from here. +""" + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +from enum import StrEnum + +from pydantic import BaseModel, Field + +from vultron.bt.report_management.states import RM + + +class OfferStatusEnum(StrEnum): + """Enumeration of Offer Statuses""" + + RECEIVED = "RECEIVED" + ACCEPTED = "ACCEPTED" + TENTATIVELY_REJECTED = "TENTATIVELY_REJECTED" + REJECTED = "REJECTED" + + +STATUS: dict[str, dict] = dict() + + +class ObjectStatus(BaseModel): + """Represents the status of an object being tracked in the Vultron Protocol.""" + + object_type: str = Field( + description="The type of the object whose status is being represented. Taken from the as_type field of the object.", + ) + object_id: str = Field( + description="The ID of the object whose status is being represented. Taken from the as_id field of the object." + ) + actor_id: str | None = Field( + default=None, + description="The actor to whom this status is relevant, if applicable.", + ) + status: str # replace with a StrEnum in subclasses + + +class OfferStatus(ObjectStatus): + """Represents the status of an Offer object.""" + + status: OfferStatusEnum = Field( + default=OfferStatusEnum.RECEIVED, + description=f"The status of the Offer. Possible values are: {', '.join([s.name for s in OfferStatusEnum])}.", + ) + + +class ReportStatus(ObjectStatus): + """Represents the status of a VulnerabilityReport object.""" + + status: RM = Field( + default=RM.RECEIVED, + description=f"The status of the VulnerabilityReport. Possible values are: {', '.join([s.name for s in RM])}.", + ) + + +def status_to_record_dict(status_record: ObjectStatus) -> dict: + """Converts an ObjectStatus instance to a nested dict suitable for STATUS storage.""" + return { + status_record.object_type: { + status_record.object_id: { + status_record.actor_id: status_record.model_dump() + } + } + } + + +def set_status(status_record: ObjectStatus) -> None: + """Sets the status of an object in the in-memory STATUS layer.""" + sl = get_status_layer() + sl.update(status_to_record_dict(status_record)) + + +def get_status_layer() -> dict[str, dict]: + """Returns the global in-memory status layer.""" + return STATUS diff --git a/vultron/core/models/vultron_types.py b/vultron/core/models/vultron_types.py new file mode 100644 index 00000000..42fd4213 --- /dev/null +++ b/vultron/core/models/vultron_types.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Backward-compatibility re-export shim for core domain model types. + +All types have been moved to individual modules under ``vultron/core/models/``. +Import directly from those modules for new code: + +- ``vultron.core.models.activity`` — VultronActivity, VultronOffer, + VultronAccept, VultronCreateCaseActivity +- ``vultron.core.models.case`` — VultronCase +- ``vultron.core.models.case_actor`` — VultronCaseActor, VultronOutbox +- ``vultron.core.models.case_event`` — VultronCaseEvent +- ``vultron.core.models.case_status`` — VultronCaseStatus +- ``vultron.core.models.embargo_event`` — VultronEmbargoEvent +- ``vultron.core.models.note`` — VultronNote +- ``vultron.core.models.participant`` — VultronParticipant +- ``vultron.core.models.participant_status`` — VultronParticipantStatus +- ``vultron.core.models.report`` — VultronReport +""" + +from vultron.core.models.activity import ( + VultronAccept, + VultronActivity, + VultronCreateCaseActivity, + VultronOffer, +) +from vultron.core.models.case import VultronCase +from vultron.core.models.case_actor import VultronCaseActor, VultronOutbox +from vultron.core.models.case_event import VultronCaseEvent +from vultron.core.models.case_status import VultronCaseStatus +from vultron.core.models.embargo_event import VultronEmbargoEvent +from vultron.core.models.note import VultronNote +from vultron.core.models.participant import VultronParticipant +from vultron.core.models.participant_status import VultronParticipantStatus +from vultron.core.models.report import VultronReport + +__all__ = [ + "VultronAccept", + "VultronActivity", + "VultronCase", + "VultronCaseActor", + "VultronCaseEvent", + "VultronCaseStatus", + "VultronCreateCaseActivity", + "VultronEmbargoEvent", + "VultronNote", + "VultronOffer", + "VultronOutbox", + "VultronParticipant", + "VultronParticipantStatus", + "VultronReport", +] diff --git a/vultron/api/v2/datalayer/abc.py b/vultron/core/ports/__init__.py similarity index 53% rename from vultron/api/v2/datalayer/abc.py rename to vultron/core/ports/__init__.py index 6a41f6d7..0f407d92 100644 --- a/vultron/api/v2/datalayer/abc.py +++ b/vultron/core/ports/__init__.py @@ -9,35 +9,13 @@ # Created, in part, with funding and support from the United States Government # (see Acknowledgments file). This program may include and/or can make use of # certain third party source code, object code, documentation and other files -# (“Third Party Software”). See LICENSE.md for more details. +# ("Third Party Software"). See LICENSE.md for more details. # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -# Copyright - -""" -Provides an abstract base class for a data layer. This is intended to define -the interface that concrete data layer implementations must follow. """ +Core ports package. -from typing import Protocol - -from vultron.api.v2.datalayer.db_record import Record - - -class DataLayer(Protocol): - """Protocol for a data layer.""" - - def create(self, record: Record) -> None: ... - - def get(self, table: str, id_: str) -> Record | None: ... - - def update(self, id_: str, record: Record) -> None: ... - - def delete(self, table: str, id_: str) -> None: ... - - def all(self, table: str) -> list[Record]: ... - - def clear_table(self, table: str) -> None: ... - - def clear_all(self) -> None: ... +Contains port (interface) definitions used by the core domain layer. +Concrete adapter implementations live in ``vultron/api/v2/datalayer/``. +""" diff --git a/vultron/core/ports/datalayer.py b/vultron/core/ports/datalayer.py new file mode 100644 index 00000000..e307f81a --- /dev/null +++ b/vultron/core/ports/datalayer.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +DataLayer port — the storage interface used by the core domain layer. + +Concrete implementations (e.g. ``TinyDbDataLayer``) live in the adapter +layer at ``vultron/api/v2/datalayer/`` and import this Protocol to +verify structural conformance. + +No adapter-layer types (``Record``, ``TinyDB``, etc.) appear here. +""" + +from typing import Any, Protocol + +from pydantic import BaseModel + + +class StorableRecord(BaseModel): + """Minimal typed record passed to ``DataLayer.create()`` and ``update()``. + + Core BT nodes construct ``StorableRecord`` objects without importing the + adapter-layer ``Record`` class. Adapter implementations receive a + ``StorableRecord`` (or a subclass such as ``Record``) and may add extra + behaviour (e.g. ``from_obj`` / ``to_obj``) without coupling the port to + wire-layer types. + """ + + id_: str + type_: str + data_: dict + + +class DataLayer(Protocol): + """Protocol for a data layer. + + Defines the minimum interface that any concrete storage adapter must + satisfy. ``update`` accepts ``StorableRecord`` — a Pydantic model + defined in this module — so that the core layer passes validated, typed + objects to the port rather than raw dicts or ``Any``. ``create`` + additionally accepts a plain ``BaseModel`` for callers that have not yet + been updated to produce ``StorableRecord`` objects (V-15 through V-19, + tracked in P65-5/P65-6). + """ + + def create(self, record: "StorableRecord | BaseModel") -> None: ... + + def read(self, object_id: str) -> BaseModel | None: ... + + def get(self, table: str | None, id_: str | None) -> Any: ... + + def update(self, id_: str, record: StorableRecord) -> None: ... + + def delete(self, table: str, id_: str) -> None: ... + + def clear_table(self, table: str) -> None: ... + + def clear_all(self) -> None: ... + + def save(self, obj: BaseModel) -> None: ... diff --git a/vultron/core/ports/delivery_queue.py b/vultron/core/ports/delivery_queue.py new file mode 100644 index 00000000..f4b6fe96 --- /dev/null +++ b/vultron/core/ports/delivery_queue.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +DeliveryQueue port — the outbound activity delivery interface used by +the core domain layer. + +Concrete implementations live in the adapter layer at +``vultron/adapters/driven/delivery_queue.py``. + +No adapter-layer types appear here. +""" + +from typing import Protocol + + +class DeliveryQueue(Protocol): + """Protocol for an outbound activity delivery queue. + + Defines the minimum interface that any concrete delivery adapter must + satisfy. The core layer calls ``enqueue`` to schedule delivery of an + outbound activity to one or more recipients; ``drain`` processes all + pending items. + """ + + def enqueue(self, activity_id: str, recipient_id: str) -> None: + """Schedule delivery of ``activity_id`` to ``recipient_id``. + + The activity is identified by its URI; the adapter resolves + the full object from the ``DataLayer`` when it processes the item. + """ + ... + + def drain(self) -> int: + """Deliver all pending queue items. + + Returns the number of items successfully delivered. + """ + ... diff --git a/vultron/core/ports/dns_resolver.py b/vultron/core/ports/dns_resolver.py new file mode 100644 index 00000000..6bb90ed7 --- /dev/null +++ b/vultron/core/ports/dns_resolver.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +""" +DnsResolver port — the DNS lookup interface used by the core domain layer +for actor and instance trust discovery. + +Concrete implementations live in the adapter layer at +``vultron/adapters/driven/dns_resolver.py``. + +No adapter-layer types appear here. +""" + +from typing import Protocol + + +class DnsResolver(Protocol): + """Protocol for DNS-based actor/instance trust discovery. + + Defines the minimum interface that any concrete DNS adapter must + satisfy. The core layer calls ``resolve_txt`` to look up TXT records + for a given domain, which may carry Vultron instance metadata such as + inbox URLs, public keys, or supported protocol versions following + WebFinger / NodeInfo conventions. + """ + + def resolve_txt(self, domain: str) -> list[str]: + """Return the DNS TXT record values for ``domain``. + + Returns an empty list when no TXT records are found or the domain + cannot be resolved. + """ + ... diff --git a/vultron/core/use_cases/__init__.py b/vultron/core/use_cases/__init__.py new file mode 100644 index 00000000..5bf4d468 --- /dev/null +++ b/vultron/core/use_cases/__init__.py @@ -0,0 +1,6 @@ +""" +Use Cases (Ports) for the Vultron Library. + +These correspond to Ports in the Ports-and-Adapters architecture, and implement business-logic named use cases. +E.g., AcceptInviteToCase, SubmitReport, DeferCase. +""" diff --git a/vultron/core/use_cases/_types.py b/vultron/core/use_cases/_types.py new file mode 100644 index 00000000..e1a74254 --- /dev/null +++ b/vultron/core/use_cases/_types.py @@ -0,0 +1,37 @@ +"""Protocol types for DataLayer-retrieved objects used by core use cases. + +Both wire-layer types (e.g. VulnerabilityCase) and domain types (e.g. +VultronCase) conform structurally to these Protocols, so use cases can call +methods on DataLayer results without importing wire-layer classes. +""" + +from typing import Protocol + + +class CaseModel(Protocol): + as_id: str + case_participants: list + vulnerability_reports: list + active_embargo: object + actor_participant_index: dict + events: list + attributed_to: object + notes: list + case_statuses: list + proposed_embargoes: list + name: str | None + + def set_embargo(self, embargo_id: str) -> None: ... + def add_participant(self, participant: object) -> None: ... + def remove_participant(self, participant_id: str) -> None: ... + def record_event(self, obj_id: str, event_type: str) -> None: ... + + @property + def current_status(self) -> object: ... + + +class ParticipantModel(Protocol): + as_id: str + accepted_embargo_ids: list + participant_statuses: list + attributed_to: object diff --git a/vultron/core/use_cases/actor.py b/vultron/core/use_cases/actor.py new file mode 100644 index 00000000..760db1d2 --- /dev/null +++ b/vultron/core/use_cases/actor.py @@ -0,0 +1,306 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +import logging +from typing import cast + +from vultron.core.models.events.actor import ( + AcceptCaseOwnershipTransferReceivedEvent, + AcceptInviteActorToCaseReceivedEvent, + AcceptSuggestActorToCaseReceivedEvent, + InviteActorToCaseReceivedEvent, + OfferCaseOwnershipTransferReceivedEvent, + RejectCaseOwnershipTransferReceivedEvent, + RejectInviteActorToCaseReceivedEvent, + RejectSuggestActorToCaseReceivedEvent, + SuggestActorToCaseReceivedEvent, +) +from vultron.core.models.vultron_types import VultronParticipant +from vultron.core.ports.datalayer import DataLayer +from vultron.core.use_cases._types import CaseModel + +logger = logging.getLogger(__name__) + + +def suggest_actor_to_case( + event: SuggestActorToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + existing = dl.get(event.activity_type, event.activity_id) + if existing is not None: + logger.info( + "RecommendActor '%s' already stored — skipping (idempotent)", + event.activity_id, + ) + return + + obj_to_store = event.activity + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info( + "Stored actor recommendation '%s' (actor=%s, object=%s, target=%s)", + event.activity_id, + event.actor_id, + event.object_id, + event.target_id, + ) + except Exception as e: + logger.error( + "Error in suggest_actor_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def accept_suggest_actor_to_case( + event: AcceptSuggestActorToCaseReceivedEvent, + dl: DataLayer, +) -> None: + try: + existing = dl.get(event.activity_type, event.activity_id) + if existing is not None: + logger.info( + "AcceptActorRecommendation '%s' already stored — skipping (idempotent)", + event.activity_id, + ) + return + + obj_to_store = event.activity + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info( + "Stored acceptance of actor recommendation '%s' (actor=%s, object=%s, target=%s)", + event.activity_id, + event.actor_id, + event.object_id, + event.target_id, + ) + except Exception as e: + logger.error( + "Error in accept_suggest_actor_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def reject_suggest_actor_to_case( + event: RejectSuggestActorToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + logger.info( + "Actor '%s' rejected recommendation to add actor '%s' to case", + event.actor_id, + event.object_id, + ) + except Exception as e: + logger.error( + "Error in reject_suggest_actor_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def offer_case_ownership_transfer( + event: OfferCaseOwnershipTransferReceivedEvent, + dl: DataLayer, +) -> None: + try: + existing = dl.get(event.activity_type, event.activity_id) + if existing is not None: + logger.info( + "OfferCaseOwnershipTransferActivity '%s' already stored — skipping (idempotent)", + event.activity_id, + ) + return + + obj_to_store = event.activity + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info( + "Stored ownership transfer offer '%s' (actor=%s, target=%s)", + event.activity_id, + event.actor_id, + event.target_id, + ) + except Exception as e: + logger.error( + "Error in offer_case_ownership_transfer for activity %s: %s", + event.activity_id, + str(e), + ) + + +def accept_case_ownership_transfer( + event: AcceptCaseOwnershipTransferReceivedEvent, dl: DataLayer +) -> None: + try: + case_id = event.inner_object_id + new_owner_id = event.actor_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "accept_case_ownership_transfer: case '%s' not found", case_id + ) + return + + current_owner_id = ( + case.attributed_to.as_id + if hasattr(case.attributed_to, "as_id") + else (str(case.attributed_to) if case.attributed_to else None) + ) + if current_owner_id == new_owner_id: + logger.info( + "Case '%s' already owned by '%s' — skipping (idempotent)", + case_id, + new_owner_id, + ) + return + + case.attributed_to = new_owner_id # type: ignore[assignment] + dl.save(case) + logger.info( + "Transferred ownership of case '%s' from '%s' to '%s'", + case_id, + current_owner_id, + new_owner_id, + ) + + except Exception as e: + logger.error( + "Error in accept_case_ownership_transfer for activity %s: %s", + event.activity_id, + str(e), + ) + + +def reject_case_ownership_transfer( + event: RejectCaseOwnershipTransferReceivedEvent, dl: DataLayer +) -> None: + try: + logger.info( + "Actor '%s' rejected ownership transfer offer '%s' — ownership unchanged", + event.actor_id, + event.object_id, + ) + except Exception as e: + logger.error( + "Error in reject_case_ownership_transfer for activity %s: %s", + event.activity_id, + str(e), + ) + + +def invite_actor_to_case( + event: InviteActorToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + existing = dl.get(event.activity_type, event.activity_id) + if existing is not None: + logger.info( + "Invite '%s' already stored — skipping (idempotent)", + event.activity_id, + ) + return + + obj_to_store = event.activity + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info( + "Stored invite '%s' (actor=%s, target=%s)", + event.activity_id, + event.actor_id, + event.target_id, + ) + except Exception as e: + logger.error( + "Error in invite_actor_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def accept_invite_actor_to_case( + event: AcceptInviteActorToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + case_id = event.inner_target_id + invitee_id = event.inner_object_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "accept_invite_actor_to_case: case '%s' not found", case_id + ) + return + + existing_ids = [ + (p.as_id if hasattr(p, "as_id") else p) + for p in case.case_participants + ] + if ( + invitee_id in case.actor_participant_index + or invitee_id in existing_ids + ): + logger.info( + "Actor '%s' already participant in case '%s' — skipping (idempotent)", + invitee_id, + case_id, + ) + return + + active_embargo_id = ( + case.active_embargo.as_id + if hasattr(case.active_embargo, "as_id") + else ( + str(case.active_embargo) + if case.active_embargo is not None + else None + ) + ) + + participant = VultronParticipant( + as_id=f"{case_id}/participants/{invitee_id.split('/')[-1]}", + attributed_to=invitee_id, + context=case_id, + ) + if active_embargo_id: + participant.accepted_embargo_ids.append(active_embargo_id) + dl.create(participant) + + # Use string IDs to avoid wire-type serialization incompatibility + case.case_participants.append(participant.as_id) + case.actor_participant_index[invitee_id] = participant.as_id + case.record_event(invitee_id, "participant_joined") + if active_embargo_id: + case.record_event(active_embargo_id, "embargo_accepted") + dl.save(case) + + logger.info( + "Added participant '%s' to case '%s' via accepted invite", + invitee_id, + case_id, + ) + + except Exception as e: + logger.error( + "Error in accept_invite_actor_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def reject_invite_actor_to_case( + event: RejectInviteActorToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + logger.info( + "Actor '%s' rejected invitation '%s'", + event.actor_id, + event.object_id, + ) + except Exception as e: + logger.error( + "Error in reject_invite_actor_to_case for activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/case.py b/vultron/core/use_cases/case.py new file mode 100644 index 00000000..367e47e5 --- /dev/null +++ b/vultron/core/use_cases/case.py @@ -0,0 +1,296 @@ +"""Use cases for vulnerability case activities.""" + +import logging +from typing import cast + +from vultron.core.models.events.case import ( + AddReportToCaseReceivedEvent, + CloseCaseReceivedEvent, + CreateCaseReceivedEvent, + DeferCaseReceivedEvent, + EngageCaseReceivedEvent, + UpdateCaseReceivedEvent, +) +from vultron.core.models.vultron_types import VultronActivity +from vultron.core.ports.datalayer import DataLayer +from vultron.core.use_cases._types import CaseModel + +logger = logging.getLogger(__name__) + + +def create_case(event: CreateCaseReceivedEvent, dl: DataLayer) -> None: + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.case.create_tree import create_create_case_tree + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + try: + actor_id = event.actor_id + case_id = event.object_id + + if event.case is None: + logger.warning( + "create_case: no case domain object in event for case '%s'", + case_id, + ) + return + + logger.info("Actor '%s' creates case '%s'", actor_id, case_id) + + case_wire = VulnerabilityCase( + id=event.case.as_id, + name=event.case.name, + attributed_to=event.case.attributed_to, + ) + + bridge = BTBridge(datalayer=dl) + tree = create_create_case_tree(case_obj=case_wire, actor_id=actor_id) + result = bridge.execute_with_setup( + tree=tree, actor_id=actor_id, activity=event + ) + + if result.status.name != "SUCCESS": + logger.warning( + "CreateCaseBT did not succeed for actor '%s' / case '%s': %s", + actor_id, + case_id, + result.feedback_message, + ) + except Exception as e: + logger.error( + "Error in create_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def engage_case(event: EngageCaseReceivedEvent, dl: DataLayer) -> None: + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.report.prioritize_tree import ( + create_engage_case_tree, + ) + + try: + actor_id = event.actor_id + case_id = event.object_id + + logger.info( + "Actor '%s' engages case '%s' (RM → ACCEPTED)", actor_id, case_id + ) + + bridge = BTBridge(datalayer=dl) + tree = create_engage_case_tree(case_id=case_id, actor_id=actor_id) + result = bridge.execute_with_setup( + tree=tree, actor_id=actor_id, activity=event + ) + + if result.status.name != "SUCCESS": + logger.warning( + "EngageCaseBT did not succeed for actor '%s' / case '%s': %s", + actor_id, + case_id, + result.feedback_message, + ) + except Exception as e: + logger.error( + "Error in engage_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def defer_case(event: DeferCaseReceivedEvent, dl: DataLayer) -> None: + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.report.prioritize_tree import ( + create_defer_case_tree, + ) + + try: + actor_id = event.actor_id + case_id = event.object_id + + logger.info( + "Actor '%s' defers case '%s' (RM → DEFERRED)", actor_id, case_id + ) + + bridge = BTBridge(datalayer=dl) + tree = create_defer_case_tree(case_id=case_id, actor_id=actor_id) + result = bridge.execute_with_setup( + tree=tree, actor_id=actor_id, activity=event + ) + + if result.status.name != "SUCCESS": + logger.warning( + "DeferCaseBT did not succeed for actor '%s' / case '%s': %s", + actor_id, + case_id, + result.feedback_message, + ) + except Exception as e: + logger.error( + "Error in defer_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def add_report_to_case( + event: AddReportToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + report_id = event.object_id + case_id = event.target_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning("add_report_to_case: case '%s' not found", case_id) + return + + existing_report_ids = [ + (r.as_id if hasattr(r, "as_id") else r) + for r in case.vulnerability_reports + ] + if report_id in existing_report_ids: + logger.info( + "Report '%s' already in case '%s' — skipping (idempotent)", + report_id, + case_id, + ) + return + + case.vulnerability_reports.append(report_id) + dl.save(case) + logger.info("Added report '%s' to case '%s'", report_id, case_id) + + except Exception as e: + logger.error( + "Error in add_report_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def close_case(event: CloseCaseReceivedEvent, dl: DataLayer) -> None: + try: + actor_id = event.actor_id + case_id = event.object_id + + logger.info("Actor '%s' is closing case '%s'", actor_id, case_id) + + close_activity = VultronActivity( + as_type="Leave", + actor=actor_id, + as_object=case_id, + ) + try: + dl.create(close_activity) + logger.info("Created Leave activity %s", close_activity.as_id) + except ValueError: + logger.info( + "Leave activity for case '%s' already exists — skipping (idempotent)", + case_id, + ) + return + + actor_obj = dl.read(actor_id) + if actor_obj is not None and hasattr(actor_obj, "outbox"): + actor_obj.outbox.items.append(close_activity.as_id) + dl.save(actor_obj) + logger.info( + "Added Leave activity %s to actor %s outbox", + close_activity.as_id, + actor_id, + ) + + except Exception as e: + logger.error( + "Error in close_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def _check_participant_embargo_acceptance( + case: CaseModel, dl: DataLayer +) -> None: + active_embargo = case.active_embargo + if active_embargo is None: + return + embargo_id = ( + active_embargo.as_id + if hasattr(active_embargo, "as_id") + else str(active_embargo) + ) + for actor_id, participant_id in case.actor_participant_index.items(): + participant = dl.read(participant_id) + if participant is None: + logger.warning( + "update_case: could not read participant '%s' for embargo acceptance check", + participant_id, + ) + continue + if not hasattr(participant, "accepted_embargo_ids"): + continue + if embargo_id not in participant.accepted_embargo_ids: + logger.warning( + "update_case: participant '%s' (actor '%s') has not accepted the active " + "embargo '%s' — case update will not be broadcast to this participant " + "(CM-10-004)", + participant_id, + actor_id, + embargo_id, + ) + + +def update_case(event: UpdateCaseReceivedEvent, dl: DataLayer) -> None: + try: + actor_id = event.actor_id + case_id = event.object_id + + stored_case = cast(CaseModel, dl.read(case_id)) + if stored_case is None: + logger.warning( + "update_case: case '%s' not found in DataLayer — skipping", + case_id, + ) + return + + owner_id = ( + stored_case.attributed_to.as_id + if hasattr(stored_case.attributed_to, "as_id") + else ( + str(stored_case.attributed_to) + if stored_case.attributed_to + else None + ) + ) + if owner_id != actor_id: + logger.warning( + "update_case: actor '%s' is not the owner of case '%s' — skipping update", + actor_id, + case_id, + ) + return + + _check_participant_embargo_acceptance(stored_case, dl) + + if event.object_type == "VulnerabilityCase" and event.case is not None: + for field in ("name", "summary", "content"): + value = getattr(event.case, field, None) + if value is not None: + setattr(stored_case, field, value) + dl.save(stored_case) + logger.info("Actor '%s' updated case '%s'", actor_id, case_id) + else: + logger.info( + "update_case: object for case '%s' is a reference only — no fields to apply", + case_id, + ) + + except Exception as e: + logger.error( + "Error in update_case for activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/case_participant.py b/vultron/core/use_cases/case_participant.py new file mode 100644 index 00000000..7c792fc7 --- /dev/null +++ b/vultron/core/use_cases/case_participant.py @@ -0,0 +1,137 @@ +"""Use cases for case participant management activities.""" + +import logging +from typing import cast + +from vultron.core.models.events.case_participant import ( + AddCaseParticipantToCaseReceivedEvent, + CreateCaseParticipantReceivedEvent, + RemoveCaseParticipantFromCaseReceivedEvent, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.core.use_cases._types import CaseModel + +logger = logging.getLogger(__name__) + + +def create_case_participant( + event: CreateCaseParticipantReceivedEvent, dl: DataLayer +) -> None: + try: + existing = dl.get(event.object_type, event.object_id) + if existing is not None: + logger.info( + "Participant '%s' already exists — skipping (idempotent)", + event.object_id, + ) + return + + obj_to_store = event.participant + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info("Created participant '%s'", event.object_id) + else: + logger.warning( + "create_case_participant: no participant object for event '%s'", + event.activity_id, + ) + + except Exception as e: + logger.error( + "Error in create_case_participant for activity %s: %s", + event.activity_id, + str(e), + ) + + +def add_case_participant_to_case( + event: AddCaseParticipantToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + participant_id = event.object_id + case_id = event.target_id + participant = dl.read(participant_id) + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "add_case_participant_to_case: case '%s' not found", case_id + ) + return + + existing_ids = [ + (p.as_id if hasattr(p, "as_id") else p) + for p in case.case_participants + ] + if participant_id in existing_ids: + logger.info( + "Participant '%s' already in case '%s' — skipping (idempotent)", + participant_id, + case_id, + ) + return + + # Use string ID to avoid wire-type serialization incompatibility + case.case_participants.append(participant_id) + if ( + hasattr(participant, "attributed_to") + and participant.attributed_to is not None + ): + actor_id = ( + participant.attributed_to.as_id + if hasattr(participant.attributed_to, "as_id") + else str(participant.attributed_to) + ) + case.actor_participant_index[actor_id] = participant_id + dl.save(case) + logger.info( + "Added participant '%s' to case '%s'", participant_id, case_id + ) + + except Exception as e: + logger.error( + "Error in add_case_participant_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def remove_case_participant_from_case( + event: RemoveCaseParticipantFromCaseReceivedEvent, dl: DataLayer +) -> None: + try: + participant_id = event.object_id + case_id = event.target_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "remove_case_participant_from_case: case '%s' not found", + case_id, + ) + return + + existing_ids = [ + (p.as_id if hasattr(p, "as_id") else p) + for p in case.case_participants + ] + if participant_id not in existing_ids: + logger.info( + "Participant '%s' not in case '%s' — skipping (idempotent)", + participant_id, + case_id, + ) + return + + case.remove_participant(participant_id) + dl.save(case) + logger.info( + "Removed participant '%s' from case '%s'", participant_id, case_id + ) + + except Exception as e: + logger.error( + "Error in remove_case_participant_from_case for activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/embargo.py b/vultron/core/use_cases/embargo.py new file mode 100644 index 00000000..79f5b22c --- /dev/null +++ b/vultron/core/use_cases/embargo.py @@ -0,0 +1,300 @@ +"""Use cases for embargo management activities.""" + +import logging +from typing import cast + +from vultron.bt.embargo_management.states import EM +from vultron.core.models.events.embargo import ( + AcceptInviteToEmbargoOnCaseReceivedEvent, + AddEmbargoEventToCaseReceivedEvent, + AnnounceEmbargoEventToCaseReceivedEvent, + CreateEmbargoEventReceivedEvent, + InviteToEmbargoOnCaseReceivedEvent, + RejectInviteToEmbargoOnCaseReceivedEvent, + RemoveEmbargoEventFromCaseReceivedEvent, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.core.use_cases._types import CaseModel, ParticipantModel + +logger = logging.getLogger(__name__) + + +def create_embargo_event( + event: CreateEmbargoEventReceivedEvent, dl: DataLayer +) -> None: + try: + existing = dl.get(event.object_type, event.object_id) + if existing is not None: + logger.info( + "EmbargoEvent '%s' already stored — skipping (idempotent)", + event.object_id, + ) + return + + obj_to_store = event.embargo + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info("Stored EmbargoEvent '%s'", event.object_id) + else: + logger.warning( + "create_embargo_event: no embargo object for event '%s'", + event.activity_id, + ) + + except Exception as e: + logger.error( + "Error in create_embargo_event for activity %s: %s", + event.activity_id, + str(e), + ) + + +def add_embargo_event_to_case( + event: AddEmbargoEventToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + embargo_id = event.object_id + case_id = event.target_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "add_embargo_event_to_case: case '%s' not found", case_id + ) + return + + current_embargo_id = ( + case.active_embargo.as_id + if hasattr(case.active_embargo, "as_id") + else ( + str(case.active_embargo) + if case.active_embargo is not None + else None + ) + ) + if current_embargo_id == embargo_id: + logger.info( + "Case '%s' already has embargo '%s' active — skipping (idempotent)", + case_id, + embargo_id, + ) + return + + case.set_embargo(embargo_id) + dl.save(case) + logger.info("Activated embargo '%s' on case '%s'", embargo_id, case_id) + + except Exception as e: + logger.error( + "Error in add_embargo_event_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def remove_embargo_event_from_case( + event: RemoveEmbargoEventFromCaseReceivedEvent, dl: DataLayer +) -> None: + try: + embargo_id = event.object_id + case_id = event.origin_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "remove_embargo_event_from_case: case '%s' not found", case_id + ) + return + + current_embargo_id = ( + case.active_embargo.as_id + if hasattr(case.active_embargo, "as_id") + else ( + str(case.active_embargo) + if case.active_embargo is not None + else None + ) + ) + if current_embargo_id != embargo_id: + logger.info( + "Case '%s' does not have embargo '%s' active — skipping", + case_id, + embargo_id, + ) + return + + case.active_embargo = None # type: ignore[attr-defined] + case.current_status.em_state = EM.EMBARGO_MANAGEMENT_NONE # type: ignore[union-attr] + dl.save(case) + logger.info("Removed embargo '%s' from case '%s'", embargo_id, case_id) + + except Exception as e: + logger.error( + "Error in remove_embargo_event_from_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def announce_embargo_event_to_case( + event: AnnounceEmbargoEventToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + logger.info( + "Received embargo announcement '%s' on case '%s'", + event.activity_id, + event.context_id, + ) + except Exception as e: + logger.error( + "Error in announce_embargo_event_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def invite_to_embargo_on_case( + event: InviteToEmbargoOnCaseReceivedEvent, + dl: DataLayer, +) -> None: + try: + existing = dl.get(event.activity_type, event.activity_id) + if existing is not None: + logger.info( + "EmProposeEmbargoActivity '%s' already stored — skipping (idempotent)", + event.activity_id, + ) + return + + obj_to_store = event.activity + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info( + "Stored embargo proposal '%s' (actor=%s, context=%s)", + event.activity_id, + event.actor_id, + event.context_id, + ) + else: + logger.warning( + "invite_to_embargo_on_case: no activity object for '%s'", + event.activity_id, + ) + + except Exception as e: + logger.error( + "Error in invite_to_embargo_on_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def accept_invite_to_embargo_on_case( + event: AcceptInviteToEmbargoOnCaseReceivedEvent, dl: DataLayer +) -> None: + try: + embargo_id = event.inner_object_id + + if event.inner_context_id: + case = cast(CaseModel, dl.read(event.inner_context_id)) + else: + invite = dl.read(event.object_id) + if invite is None: + logger.error( + "accept_invite_to_embargo_on_case: invite '%s' not found", + event.object_id, + ) + return + context_id = getattr(invite, "context", None) + context_id = ( + context_id.as_id + if hasattr(context_id, "as_id") + else str(context_id) if context_id else None + ) + if context_id is None: + logger.error( + "accept_invite_to_embargo_on_case: cannot determine case from invite '%s'", + event.object_id, + ) + return + case = cast(CaseModel, dl.read(context_id)) + + if case is None: + logger.error("accept_invite_to_embargo_on_case: case not found") + return + case_id = case.as_id + + current_embargo_id = ( + case.active_embargo.as_id + if hasattr(case.active_embargo, "as_id") + else ( + str(case.active_embargo) + if case.active_embargo is not None + else None + ) + ) + if current_embargo_id == embargo_id: + logger.info( + "Case '%s' already has embargo '%s' active — skipping (idempotent)", + case_id, + embargo_id, + ) + return + + case.set_embargo(embargo_id) + + accepting_actor_id = event.actor_id + participant_id = case.actor_participant_index.get(accepting_actor_id) + if participant_id: + participant = cast(ParticipantModel, dl.read(participant_id)) + if ( + participant is not None + and embargo_id not in participant.accepted_embargo_ids + ): + participant.accepted_embargo_ids.append(embargo_id) + dl.save(participant) + logger.info( + "Recorded embargo acceptance '%s' for participant '%s'", + embargo_id, + accepting_actor_id, + ) + else: + logger.warning( + "Accepting actor '%s' has no CaseParticipant in case '%s' — " + "cannot record embargo acceptance", + accepting_actor_id, + case_id, + ) + + case.record_event(embargo_id, "embargo_accepted") + dl.save(case) + logger.info( + "Accepted embargo proposal '%s'; activated embargo '%s' on case '%s'", + event.object_id, + embargo_id, + case_id, + ) + + except Exception as e: + logger.error( + "Error in accept_invite_to_embargo_on_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def reject_invite_to_embargo_on_case( + event: RejectInviteToEmbargoOnCaseReceivedEvent, dl: DataLayer +) -> None: + try: + logger.info( + "Actor '%s' rejected embargo proposal '%s'", + event.actor_id, + event.object_id, + ) + except Exception as e: + logger.error( + "Error in reject_invite_to_embargo_on_case for activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/note.py b/vultron/core/use_cases/note.py new file mode 100644 index 00000000..f92faaf2 --- /dev/null +++ b/vultron/core/use_cases/note.py @@ -0,0 +1,116 @@ +"""Use cases for case note activities.""" + +import logging +from typing import cast + +from vultron.core.models.events.note import ( + AddNoteToCaseReceivedEvent, + CreateNoteReceivedEvent, + RemoveNoteFromCaseReceivedEvent, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.core.use_cases._types import CaseModel + +logger = logging.getLogger(__name__) + + +def create_note(event: CreateNoteReceivedEvent, dl: DataLayer) -> None: + try: + existing = dl.get(event.object_type, event.object_id) + if existing is not None: + logger.info( + "Note '%s' already stored — skipping (idempotent)", + event.object_id, + ) + return + + obj_to_store = event.note + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info("Stored Note '%s'", event.object_id) + else: + logger.warning( + "create_note: no note object for event '%s'", + event.activity_id, + ) + + except Exception as e: + logger.error( + "Error in create_note for activity %s: %s", + event.activity_id, + str(e), + ) + + +def add_note_to_case(event: AddNoteToCaseReceivedEvent, dl: DataLayer) -> None: + try: + note_id = event.object_id + case_id = event.target_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning("add_note_to_case: case '%s' not found", case_id) + return + + existing_ids = [ + (n.as_id if hasattr(n, "as_id") else n) for n in case.notes + ] + if note_id in existing_ids: + logger.info( + "Note '%s' already in case '%s' — skipping (idempotent)", + note_id, + case_id, + ) + return + + case.notes.append(note_id) + dl.save(case) + logger.info("Added note '%s' to case '%s'", note_id, case_id) + + except Exception as e: + logger.error( + "Error in add_note_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def remove_note_from_case( + event: RemoveNoteFromCaseReceivedEvent, dl: DataLayer +) -> None: + try: + note_id = event.object_id + case_id = event.target_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "remove_note_from_case: case '%s' not found", case_id + ) + return + + existing_ids = [ + (n.as_id if hasattr(n, "as_id") else n) for n in case.notes + ] + if note_id not in existing_ids: + logger.info( + "Note '%s' not in case '%s' — skipping (idempotent)", + note_id, + case_id, + ) + return + + case.notes = [ # type: ignore[assignment] + n + for n in case.notes + if (n.as_id if hasattr(n, "as_id") else n) != note_id + ] + dl.save(case) + logger.info("Removed note '%s' from case '%s'", note_id, case_id) + + except Exception as e: + logger.error( + "Error in remove_note_from_case for activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/report.py b/vultron/core/use_cases/report.py new file mode 100644 index 00000000..2b2657ed --- /dev/null +++ b/vultron/core/use_cases/report.py @@ -0,0 +1,237 @@ +"""Use cases for vulnerability report activities.""" + +import logging + +from vultron.core.models.events.report import ( + AckReportReceivedEvent, + CloseReportReceivedEvent, + CreateReportReceivedEvent, + InvalidateReportReceivedEvent, + SubmitReportReceivedEvent, + ValidateReportReceivedEvent, +) +from vultron.core.models.status import ( + OfferStatus, + OfferStatusEnum, + ReportStatus, + set_status, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.bt.report_management.states import RM + +logger = logging.getLogger(__name__) + + +def create_report(event: CreateReportReceivedEvent, dl: DataLayer) -> None: + obj_to_store = event.report + if obj_to_store is not None: + try: + dl.create(obj_to_store) + logger.info( + "Stored VulnerabilityReport with ID: %s", event.object_id + ) + except ValueError as e: + logger.warning( + "VulnerabilityReport %s already exists: %s", event.object_id, e + ) + + if event.activity is not None: + try: + dl.create(event.activity) + logger.info( + "Stored CreateReport activity with ID: %s", event.activity_id + ) + except ValueError as e: + logger.warning( + "CreateReport activity %s already exists: %s", + event.activity_id, + e, + ) + + +def submit_report(event: SubmitReportReceivedEvent, dl: DataLayer) -> None: + obj_to_store = event.report + if obj_to_store is not None: + try: + dl.create(obj_to_store) + logger.info( + "Stored VulnerabilityReport with ID: %s", event.object_id + ) + except ValueError as e: + logger.warning( + "VulnerabilityReport %s already exists: %s", event.object_id, e + ) + + if event.activity is not None: + try: + dl.create(event.activity) + logger.info( + "Stored SubmitReport activity with ID: %s", event.activity_id + ) + except ValueError as e: + logger.warning( + "SubmitReport activity %s already exists: %s", + event.activity_id, + e, + ) + + +def validate_report(event: ValidateReportReceivedEvent, dl: DataLayer) -> None: + from py_trees.common import Status + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.report.validate_tree import ( + create_validate_report_tree, + ) + + actor_id = event.actor_id + report_id = event.inner_object_id + offer_id = event.object_id + + logger.info( + "Actor '%s' validates VulnerabilityReport '%s' via BT execution", + actor_id, + report_id, + ) + + bridge = BTBridge(datalayer=dl) + tree = create_validate_report_tree(report_id=report_id, offer_id=offer_id) + result = bridge.execute_with_setup(tree, actor_id=actor_id, activity=event) + + if result.status == Status.SUCCESS: + logger.info("✓ BT validation succeeded for report: %s", report_id) + elif result.status == Status.FAILURE: + logger.error( + "✗ BT validation failed for report: %s — %s", + report_id, + result.feedback_message, + ) + for err in result.errors or []: + logger.error(" - %s", err) + else: + logger.warning( + "⚠ BT validation incomplete for report: %s (status=%s)", + report_id, + result.status, + ) + + +def invalidate_report( + event: InvalidateReportReceivedEvent, dl: DataLayer +) -> None: + try: + actor_id = event.actor_id + logger.info( + "Actor '%s' tentatively rejects offer '%s' of VulnerabilityReport '%s'", + actor_id, + event.object_id, + event.inner_object_id, + ) + set_status( + OfferStatus( + object_type=event.object_type or "Offer", + object_id=event.object_id, + status=OfferStatusEnum.TENTATIVELY_REJECTED, + actor_id=actor_id, + ) + ) + set_status( + ReportStatus( + object_type=event.inner_object_type or "VulnerabilityReport", + object_id=event.inner_object_id, + status=RM.INVALID, + actor_id=actor_id, + ) + ) + if event.activity is not None: + try: + dl.create(event.activity) + logger.info( + "Stored InvalidateReport activity with ID: %s", + event.activity_id, + ) + except ValueError as e: + logger.warning( + "InvalidateReport activity %s already exists: %s", + event.activity_id, + e, + ) + except Exception as e: + logger.error( + "Error invalidating report in activity %s: %s", + event.activity_id, + str(e), + ) + + +def ack_report(event: AckReportReceivedEvent, dl: DataLayer) -> None: + try: + logger.info( + "Actor '%s' acknowledges receipt of offer '%s' of VulnerabilityReport '%s'", + event.actor_id, + event.object_id, + event.inner_object_id, + ) + if event.activity is not None: + try: + dl.create(event.activity) + logger.info( + "Stored AckReport activity with ID: %s", event.activity_id + ) + except ValueError as e: + logger.warning( + "AckReport activity %s already exists: %s", + event.activity_id, + e, + ) + except Exception as e: + logger.error( + "Error acknowledging report in activity %s: %s", + event.activity_id, + str(e), + ) + + +def close_report(event: CloseReportReceivedEvent, dl: DataLayer) -> None: + try: + actor_id = event.actor_id + logger.info( + "Actor '%s' rejects offer '%s' of VulnerabilityReport '%s'", + actor_id, + event.object_id, + event.inner_object_id, + ) + set_status( + OfferStatus( + object_type=event.object_type or "Offer", + object_id=event.object_id, + status=OfferStatusEnum.REJECTED, + actor_id=actor_id, + ) + ) + set_status( + ReportStatus( + object_type=event.inner_object_type or "VulnerabilityReport", + object_id=event.inner_object_id, + status=RM.CLOSED, + actor_id=actor_id, + ) + ) + if event.activity is not None: + try: + dl.create(event.activity) + logger.info( + "Stored CloseReport activity with ID: %s", + event.activity_id, + ) + except ValueError as e: + logger.warning( + "CloseReport activity %s already exists: %s", + event.activity_id, + e, + ) + except Exception as e: + logger.error( + "Error closing report in activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/status.py b/vultron/core/use_cases/status.py new file mode 100644 index 00000000..aa125e47 --- /dev/null +++ b/vultron/core/use_cases/status.py @@ -0,0 +1,168 @@ +"""Use cases for case and participant status activities.""" + +import logging +from typing import cast + +from vultron.core.models.events.status import ( + AddCaseStatusToCaseReceivedEvent, + AddParticipantStatusToParticipantReceivedEvent, + CreateCaseStatusReceivedEvent, + CreateParticipantStatusReceivedEvent, +) +from vultron.core.ports.datalayer import DataLayer +from vultron.core.use_cases._types import CaseModel, ParticipantModel + +logger = logging.getLogger(__name__) + + +def create_case_status( + event: CreateCaseStatusReceivedEvent, dl: DataLayer +) -> None: + try: + existing = dl.get(event.object_type, event.object_id) + if existing is not None: + logger.info( + "CaseStatus '%s' already stored — skipping (idempotent)", + event.object_id, + ) + return + + obj_to_store = event.status + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info("Stored CaseStatus '%s'", event.object_id) + else: + logger.warning( + "create_case_status: no status object for event '%s'", + event.activity_id, + ) + + except Exception as e: + logger.error( + "Error in create_case_status for activity %s: %s", + event.activity_id, + str(e), + ) + + +def add_case_status_to_case( + event: AddCaseStatusToCaseReceivedEvent, dl: DataLayer +) -> None: + try: + status_id = event.object_id + case_id = event.target_id + case = cast(CaseModel, dl.read(case_id)) + + if case is None: + logger.warning( + "add_case_status_to_case: case '%s' not found", case_id + ) + return + + existing_ids = [ + (s.as_id if hasattr(s, "as_id") else s) for s in case.case_statuses + ] + if status_id in existing_ids: + logger.info( + "CaseStatus '%s' already in case '%s' — skipping (idempotent)", + status_id, + case_id, + ) + return + + # Prefer the domain object from the event over dl.read, which may return + # a raw TinyDB Document if reconstitution of the stored VultronCaseStatus + # record fails (wire CaseStatus has different field types). + status_obj = dl.read(status_id) + if not hasattr(status_obj, "as_id"): + status_obj = event.status + case.case_statuses.append(status_obj) + dl.save(case) + logger.info("Added CaseStatus '%s' to case '%s'", status_id, case_id) + + except Exception as e: + logger.error( + "Error in add_case_status_to_case for activity %s: %s", + event.activity_id, + str(e), + ) + + +def create_participant_status( + event: CreateParticipantStatusReceivedEvent, + dl: DataLayer, +) -> None: + try: + existing = dl.get(event.object_type, event.object_id) + if existing is not None: + logger.info( + "ParticipantStatus '%s' already stored — skipping (idempotent)", + event.object_id, + ) + return + + obj_to_store = event.status + if obj_to_store is not None: + dl.create(obj_to_store) + logger.info("Stored ParticipantStatus '%s'", event.object_id) + else: + logger.warning( + "create_participant_status: no status object for event '%s'", + event.activity_id, + ) + + except Exception as e: + logger.error( + "Error in create_participant_status for activity %s: %s", + event.activity_id, + str(e), + ) + + +def add_participant_status_to_participant( + event: AddParticipantStatusToParticipantReceivedEvent, + dl: DataLayer, +) -> None: + try: + status_id = event.object_id + participant_id = event.target_id + participant = cast(ParticipantModel, dl.read(participant_id)) + + if participant is None: + logger.warning( + "add_participant_status_to_participant: participant '%s' not found", + participant_id, + ) + return + + existing_ids = [ + (s.as_id if hasattr(s, "as_id") else s) + for s in participant.participant_statuses + ] + if status_id in existing_ids: + logger.info( + "ParticipantStatus '%s' already on participant '%s' — skipping (idempotent)", + status_id, + participant_id, + ) + return + + # Prefer the domain object from the event over dl.read, which may return + # a raw TinyDB Document when VultronParticipantStatus reconstitution fails. + status_obj = dl.read(status_id) + if not hasattr(status_obj, "as_id"): + status_obj = event.status + participant.participant_statuses.append(status_obj) + dl.save(participant) + logger.info( + "Added ParticipantStatus '%s' to participant '%s'", + status_id, + participant_id, + ) + + except Exception as e: + logger.error( + "Error in add_participant_status_to_participant for activity %s: %s", + event.activity_id, + str(e), + ) diff --git a/vultron/core/use_cases/unknown.py b/vultron/core/use_cases/unknown.py new file mode 100644 index 00000000..e25c7147 --- /dev/null +++ b/vultron/core/use_cases/unknown.py @@ -0,0 +1,12 @@ +"""Use case for unknown/unrecognized activities.""" + +import logging + +from vultron.core.models.events.unknown import UnknownReceivedEvent +from vultron.core.ports.datalayer import DataLayer + +logger = logging.getLogger(__name__) + + +def unknown(event: UnknownReceivedEvent, dl: DataLayer) -> None: + logger.warning("unknown use case called for event: %s", event) diff --git a/vultron/demo/acknowledge_demo.py b/vultron/demo/acknowledge_demo.py index 6dd1515c..97812764 100644 --- a/vultron/demo/acknowledge_demo.py +++ b/vultron/demo/acknowledge_demo.py @@ -16,19 +16,19 @@ """ Demonstrates the acknowledgement workflow for vulnerability reports via the Vultron API. -This demo script showcases the RmReadReport (as:Read) acknowledgement mechanism: +This demo script showcases the RmReadReportActivity (as:Read) acknowledgement mechanism: -1. Acknowledge Only: submit → ack (RmReadReport) → notify finder +1. Acknowledge Only: submit → ack (RmReadReportActivity) → notify finder 2. Acknowledge then Validate: submit → ack → validate → notify finder 3. Acknowledge then Invalidate: submit → ack → invalidate → notify finder The acknowledge workflow corresponds to: docs/howto/activitypub/activities/acknowledge.md -As described in that document, RmReadReport acknowledges receipt without +As described in that document, RmReadReportActivity acknowledges receipt without committing to an outcome. The receiver can subsequently validate or invalidate -the report. Sending RmValidateReport or RmInvalidateReport already implies that -the report was read, so a separate RmReadReport is optional but demonstrates +the report. Sending RmValidateReportActivity or RmInvalidateReportActivity already implies that +the report was read, so a separate RmReadReportActivity is optional but demonstrates good protocol hygiene. When run as a script, this module will: @@ -36,9 +36,9 @@ 2. Reset the data layer to a clean state 3. Discover actors (finder, vendor, coordinator) via the API 4. Run three separate demo workflows, each with a unique report: - - demo_acknowledge_only: Submit → RmReadReport → notify finder - - demo_acknowledge_then_validate: Submit → RmReadReport → Validate → notify finder - - demo_acknowledge_then_invalidate: Submit → RmReadReport → Invalidate → notify finder + - demo_acknowledge_only: Submit → RmReadReportActivity → notify finder + - demo_acknowledge_then_validate: Submit → RmReadReportActivity → Validate → notify finder + - demo_acknowledge_then_invalidate: Submit → RmReadReportActivity → Invalidate → notify finder 5. Verify side effects in the data layer and finder's inbox for each workflow """ @@ -49,14 +49,16 @@ # Vultron imports from vultron.api.v2.data.utils import parse_id -from vultron.as_vocab.activities.report import ( - RmInvalidateReport, - RmReadReport, - RmSubmitReport, - RmValidateReport, +from vultron.wire.as2.vocab.activities.report import ( + RmInvalidateReportActivity, + RmReadReportActivity, + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, ) -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -130,12 +132,12 @@ def demo_acknowledge_only( Workflow: 1. Finder submits report to vendor - 2. Vendor sends RmReadReport (as:Read) back to their own inbox - 3. Vendor notifies finder with an RmReadReport + 2. Vendor sends RmReadReportActivity (as:Read) back to their own inbox + 3. Vendor notifies finder with an RmReadReportActivity 4. Verify ack stored and delivered to finder """ logger.info("=" * 80) - logger.info("DEMO 1: Acknowledge Only (RmReadReport)") + logger.info("DEMO 1: Acknowledge Only (RmReadReportActivity)") logger.info("=" * 80) with demo_step("Step 1: Finder submits vulnerability report to vendor"): @@ -145,7 +147,7 @@ def demo_acknowledge_only( name="Network Parser Integer Overflow", ) logger.info(f"Created report: {logfmt(report)}") - offer = RmSubmitReport( + offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -156,34 +158,34 @@ def demo_acknowledge_only( verify_object_stored(client=client, obj_id=report.as_id) with demo_step( - "Step 2: Vendor acknowledges report (RmReadReport to own inbox)" + "Step 2: Vendor acknowledges report (RmReadReportActivity to own inbox)" ): stored_offer = get_offer_from_datalayer( client, vendor.as_id, offer.as_id ) - ack = RmReadReport( + ack = RmReadReportActivity( actor=vendor.as_id, as_object=stored_offer.as_id, content="We have received your report and will review it shortly.", ) post_to_inbox_and_wait(client, vendor.as_id, ack) - with demo_check("RmReadReport activity stored"): + with demo_check("RmReadReportActivity activity stored"): verify_object_stored(client=client, obj_id=ack.as_id) with demo_step("Step 3: Vendor notifies finder of acknowledgement"): - ack_to_finder = RmReadReport( + ack_to_finder = RmReadReportActivity( actor=vendor.as_id, as_object=stored_offer.as_id, to=[finder.as_id], content="We have received your report and will review it shortly.", ) post_to_inbox_and_wait(client, finder.as_id, ack_to_finder) - with demo_check("RmReadReport notification in finder's inbox"): + with demo_check("RmReadReportActivity notification in finder's inbox"): if not verify_activity_in_inbox( client, finder.as_id, ack_to_finder.as_id ): raise ValueError( - "RmReadReport notification not found in finder's inbox." + "RmReadReportActivity notification not found in finder's inbox." ) logger.info("✅ DEMO 1 COMPLETE: Report acknowledged (read-only).") @@ -197,8 +199,8 @@ def demo_acknowledge_then_validate( Workflow: 1. Finder submits report to vendor - 2. Vendor sends RmReadReport (as:Read) - 3. Vendor then sends RmValidateReport (as:Accept) + 2. Vendor sends RmReadReportActivity (as:Read) + 3. Vendor then sends RmValidateReportActivity (as:Accept) 4. Verify both activities stored; verify validate notification to finder """ logger.info("=" * 80) @@ -214,7 +216,7 @@ def demo_acknowledge_then_validate( name="Admin Login SQL Injection", ) logger.info(f"Created report: {logfmt(report)}") - offer = RmSubmitReport( + offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -224,43 +226,49 @@ def demo_acknowledge_then_validate( verify_object_stored(client=client, obj_id=offer.as_id) verify_object_stored(client=client, obj_id=report.as_id) - with demo_step("Step 2: Vendor acknowledges report (RmReadReport)"): + with demo_step( + "Step 2: Vendor acknowledges report (RmReadReportActivity)" + ): stored_offer = get_offer_from_datalayer( client, vendor.as_id, offer.as_id ) - ack = RmReadReport( + ack = RmReadReportActivity( actor=vendor.as_id, as_object=stored_offer.as_id, content="Report received — under review.", ) post_to_inbox_and_wait(client, vendor.as_id, ack) - with demo_check("RmReadReport activity stored"): + with demo_check("RmReadReportActivity activity stored"): verify_object_stored(client=client, obj_id=ack.as_id) - with demo_step("Step 3: Vendor validates report (RmValidateReport)"): - validate = RmValidateReport( + with demo_step( + "Step 3: Vendor validates report (RmValidateReportActivity)" + ): + validate = RmValidateReportActivity( actor=vendor.as_id, object=stored_offer.as_id, content="Confirmed SQL injection. Creating a case.", ) post_to_inbox_and_wait(client, vendor.as_id, validate) - with demo_check("RmValidateReport activity stored"): + with demo_check("RmValidateReportActivity activity stored"): verify_object_stored(client=client, obj_id=validate.as_id) with demo_step("Step 4: Vendor notifies finder of validation"): - validate_to_finder = RmValidateReport( + validate_to_finder = RmValidateReportActivity( actor=vendor.as_id, object=stored_offer.as_id, to=[finder.as_id], content="Your report has been validated. A case has been created.", ) post_to_inbox_and_wait(client, finder.as_id, validate_to_finder) - with demo_check("RmValidateReport notification in finder's inbox"): + with demo_check( + "RmValidateReportActivity notification in finder's inbox" + ): if not verify_activity_in_inbox( client, finder.as_id, validate_to_finder.as_id ): raise ValueError( - "RmValidateReport notification not found in finder's inbox." + "RmValidateReportActivity notification not found in finder's inbox." ) logger.info( @@ -276,8 +284,8 @@ def demo_acknowledge_then_invalidate( Workflow: 1. Finder submits report to vendor - 2. Vendor sends RmReadReport (as:Read) - 3. Vendor sends RmInvalidateReport (as:TentativeReject) + 2. Vendor sends RmReadReportActivity (as:Read) + 3. Vendor sends RmInvalidateReportActivity (as:TentativeReject) 4. Vendor notifies finder of the invalidation 5. Verify activities stored and invalidation in finder's inbox """ @@ -294,7 +302,7 @@ def demo_acknowledge_then_invalidate( name="Empty Form Submission Crash", ) logger.info(f"Created report: {logfmt(report)}") - offer = RmSubmitReport( + offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -304,21 +312,25 @@ def demo_acknowledge_then_invalidate( verify_object_stored(client=client, obj_id=offer.as_id) verify_object_stored(client=client, obj_id=report.as_id) - with demo_step("Step 2: Vendor acknowledges report (RmReadReport)"): + with demo_step( + "Step 2: Vendor acknowledges report (RmReadReportActivity)" + ): stored_offer = get_offer_from_datalayer( client, vendor.as_id, offer.as_id ) - ack = RmReadReport( + ack = RmReadReportActivity( actor=vendor.as_id, as_object=stored_offer.as_id, content="Report received — under review.", ) post_to_inbox_and_wait(client, vendor.as_id, ack) - with demo_check("RmReadReport activity stored"): + with demo_check("RmReadReportActivity activity stored"): verify_object_stored(client=client, obj_id=ack.as_id) - with demo_step("Step 3: Vendor invalidates report (RmInvalidateReport)"): - invalidate = RmInvalidateReport( + with demo_step( + "Step 3: Vendor invalidates report (RmInvalidateReportActivity)" + ): + invalidate = RmInvalidateReportActivity( actor=vendor.as_id, object=stored_offer.as_id, content=( @@ -327,11 +339,11 @@ def demo_acknowledge_then_invalidate( ), ) post_to_inbox_and_wait(client, vendor.as_id, invalidate) - with demo_check("RmInvalidateReport activity stored"): + with demo_check("RmInvalidateReportActivity activity stored"): verify_object_stored(client=client, obj_id=invalidate.as_id) with demo_step("Step 4: Vendor notifies finder of invalidation"): - invalidate_to_finder = RmInvalidateReport( + invalidate_to_finder = RmInvalidateReportActivity( actor=vendor.as_id, object=stored_offer.as_id, to=[finder.as_id], @@ -340,12 +352,14 @@ def demo_acknowledge_then_invalidate( ), ) post_to_inbox_and_wait(client, finder.as_id, invalidate_to_finder) - with demo_check("RmInvalidateReport notification in finder's inbox"): + with demo_check( + "RmInvalidateReportActivity notification in finder's inbox" + ): if not verify_activity_in_inbox( client, finder.as_id, invalidate_to_finder.as_id ): raise ValueError( - "RmInvalidateReport notification not found in finder's inbox." + "RmInvalidateReportActivity notification not found in finder's inbox." ) logger.info( diff --git a/vultron/demo/establish_embargo_demo.py b/vultron/demo/establish_embargo_demo.py index dfc80b51..f9f72a68 100644 --- a/vultron/demo/establish_embargo_demo.py +++ b/vultron/demo/establish_embargo_demo.py @@ -47,30 +47,37 @@ from datetime import datetime, timedelta from typing import Optional, Sequence, Tuple -from vultron.as_vocab.activities.case import ( - AddReportToCase, - CreateCase, - RmAcceptInviteToCase, - RmInviteToCase, +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, ) -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.embargo import ( - ActivateEmbargo, - AnnounceEmbargo, - EmAcceptEmbargo, - EmProposeEmbargo, - EmRejectEmbargo, +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, ) -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.activities.embargo import ( + ActivateEmbargoActivity, + AnnounceEmbargoActivity, + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, + EmRejectEmbargoActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( CoordinatorParticipant, FinderReporterParticipant, ) -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -132,7 +139,7 @@ def _setup_two_participant_case( content="A use-after-free vulnerability in the network stack.", name="Use-After-Free in Network Stack", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -141,7 +148,7 @@ def _setup_two_participant_case( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — use-after-free via unsanitized network input.", @@ -153,14 +160,14 @@ def _setup_two_participant_case( name="UAF Case — Network Stack", content="Tracking the use-after-free vulnerability in the network stack.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) verify_object_stored(client, case.as_id) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -179,7 +186,7 @@ def _setup_two_participant_case( post_to_inbox_and_wait(client, vendor.as_id, create_participant_activity) verify_object_stored(client, participant.as_id) - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, @@ -187,7 +194,7 @@ def _setup_two_participant_case( post_to_inbox_and_wait(client, vendor.as_id, add_participant_activity) # Invite coordinator and have them accept - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( actor=vendor.as_id, object=coordinator.as_id, target=case.as_id, @@ -196,7 +203,7 @@ def _setup_two_participant_case( ) post_to_inbox_and_wait(client, coordinator.as_id, invite) - accept = RmAcceptInviteToCase( + accept = RmAcceptInviteToCaseActivity( actor=coordinator.as_id, object=invite.as_id, to=[vendor.as_id], @@ -222,9 +229,9 @@ def demo_propose_embargo_accept( Steps: 1. Setup: initialize case with two participants (vendor + coordinator) - 2. Coordinator proposes embargo (EmProposeEmbargo → vendor inbox) - 3. Vendor accepts embargo (EmAcceptEmbargo → coordinator inbox, then - vendor activates via ActivateEmbargo → vendor's own processing) + 2. Coordinator proposes embargo (EmProposeEmbargoActivity → vendor inbox) + 3. Vendor accepts embargo (EmAcceptEmbargoActivity → coordinator inbox, then + vendor activates via ActivateEmbargoActivity → vendor's own processing) 4. Vendor announces embargo to all participants 5. Verify case has ACTIVE embargo @@ -246,7 +253,7 @@ def demo_propose_embargo_accept( ) post_to_inbox_and_wait(client, vendor.as_id, create_embargo) - proposal = EmProposeEmbargo( + proposal = EmProposeEmbargoActivity( id=f"{case.as_id}/embargo_proposals/1", actor=coordinator.as_id, object=embargo, @@ -258,7 +265,7 @@ def demo_propose_embargo_accept( post_to_inbox_and_wait(client, vendor.as_id, proposal) with demo_step("Step 3: Vendor accepts embargo and activates it"): - accept = EmAcceptEmbargo( + accept = EmAcceptEmbargoActivity( actor=vendor.as_id, object=proposal.as_id, context=case.as_id, @@ -268,7 +275,7 @@ def demo_propose_embargo_accept( logger.info(f"Sending embargo acceptance: {logfmt(accept)}") post_to_inbox_and_wait(client, coordinator.as_id, accept) - activate = ActivateEmbargo( + activate = ActivateEmbargoActivity( actor=vendor.as_id, object=embargo.as_id, target=case.as_id, @@ -279,7 +286,7 @@ def demo_propose_embargo_accept( post_to_inbox_and_wait(client, vendor.as_id, activate) with demo_step("Step 4: Vendor announces embargo to participants"): - announce = AnnounceEmbargo( + announce = AnnounceEmbargoActivity( actor=vendor.as_id, object=embargo.as_id, context=case.as_id, @@ -321,8 +328,8 @@ def demo_propose_embargo_reject( Steps: 1. Setup: initialize case with two participants (vendor + coordinator) - 2. Coordinator proposes embargo (EmProposeEmbargo → vendor inbox) - 3. Vendor rejects embargo (EmRejectEmbargo → coordinator inbox) + 2. Coordinator proposes embargo (EmProposeEmbargoActivity → vendor inbox) + 3. Vendor rejects embargo (EmRejectEmbargoActivity → coordinator inbox) 4. Verify case has no active embargo This follows the reject branch in @@ -343,7 +350,7 @@ def demo_propose_embargo_reject( ) post_to_inbox_and_wait(client, vendor.as_id, create_embargo) - proposal = EmProposeEmbargo( + proposal = EmProposeEmbargoActivity( id=f"{case.as_id}/embargo_proposals/1", actor=coordinator.as_id, object=embargo, @@ -355,7 +362,7 @@ def demo_propose_embargo_reject( post_to_inbox_and_wait(client, vendor.as_id, proposal) with demo_step("Step 3: Vendor rejects embargo proposal"): - reject = EmRejectEmbargo( + reject = EmRejectEmbargoActivity( actor=vendor.as_id, object=proposal.as_id, context=case.as_id, diff --git a/vultron/demo/initialize_case_demo.py b/vultron/demo/initialize_case_demo.py index 0223e5b6..fe443948 100644 --- a/vultron/demo/initialize_case_demo.py +++ b/vultron/demo/initialize_case_demo.py @@ -47,17 +47,27 @@ from typing import Optional, Sequence, Tuple # Vultron imports -from vultron.as_vocab.activities.case import AddReportToCase, CreateCase -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, +) +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.objects.case_participant import ( FinderReporterParticipant, VendorParticipant, ) -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -85,12 +95,12 @@ def demo_initialize_case( Steps: 1. Finder submits a vulnerability report to vendor inbox - 2. Vendor validates the report (RmValidateReport) - 3. Vendor explicitly creates a VulnerabilityCase (CreateCase) + 2. Vendor validates the report (RmValidateReportActivity) + 3. Vendor explicitly creates a VulnerabilityCase (CreateCaseActivity) 4. Vendor adds themselves as VendorParticipant (case creator/owner) - 5. Vendor adds the report to the case (AddReportToCase) + 5. Vendor adds the report to the case (AddReportToCaseActivity) 6. Vendor creates a FinderReporterParticipant for the finder - 7. Vendor adds the finder participant to the case (AddParticipantToCase) + 7. Vendor adds the finder participant to the case (AddParticipantToCaseActivity) 8. Final case state is logged This follows the workflow in docs/howto/activitypub/activities/initialize_case.md. @@ -109,7 +119,7 @@ def demo_initialize_case( name="Remote Code Execution Vulnerability", ) logger.info(f"Created report: {logfmt(report)}") - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -122,7 +132,7 @@ def demo_initialize_case( offer = get_offer_from_datalayer( client, vendor.as_id, report_offer.as_id ) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — remote code execution via unsanitized input.", @@ -136,15 +146,15 @@ def demo_initialize_case( content="Tracking the RCE vulnerability in the web framework.", ) logger.info(f"Created case object: {logfmt(case)}") - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) with demo_check("Case stored in data layer"): verify_object_stored(client, case.as_id) - with demo_check("Case state after CreateCase"): - log_case_state(client, case.as_id, "after CreateCase") + with demo_check("Case state after CreateCaseActivity"): + log_case_state(client, case.as_id, "after CreateCaseActivity") with demo_step("Step 4: Vendor adds themselves as case participant"): vendor_participant = VendorParticipant( @@ -165,7 +175,7 @@ def demo_initialize_case( with demo_check("Vendor participant stored"): verify_object_stored(client, vendor_participant.as_id) - add_vendor_participant_activity = AddParticipantToCase( + add_vendor_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=vendor_participant.as_id, target=case.as_id, @@ -175,7 +185,7 @@ def demo_initialize_case( ) with demo_check("Vendor added to case participant list"): vendor_case = log_case_state( - client, case.as_id, "after vendor AddParticipantToCase" + client, case.as_id, "after vendor AddParticipantToCaseActivity" ) if vendor_case and vendor_participant.as_id not in [ (p.as_id if hasattr(p, "as_id") else p) @@ -183,12 +193,12 @@ def demo_initialize_case( ]: raise ValueError( f"Vendor participant '{vendor_participant.as_id}' not found in" - " case after AddParticipantToCase" + " case after AddParticipantToCaseActivity" ) logger.info("Vendor added as participant to case") with demo_step("Step 5: Vendor links report to case"): - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -196,14 +206,14 @@ def demo_initialize_case( post_to_inbox_and_wait(client, vendor.as_id, add_report_activity) with demo_check("Report linked to case"): updated_case = log_case_state( - client, case.as_id, "after AddReportToCase" + client, case.as_id, "after AddReportToCaseActivity" ) if updated_case and report.as_id not in [ (r.as_id if hasattr(r, "as_id") else r) for r in updated_case.vulnerability_reports ]: raise ValueError( - f"Report '{report.as_id}' not found in case after AddReportToCase" + f"Report '{report.as_id}' not found in case after AddReportToCaseActivity" ) with demo_step("Step 6: Vendor creates finder participant"): @@ -224,7 +234,7 @@ def demo_initialize_case( verify_object_stored(client, participant.as_id) with demo_step("Step 7: Vendor adds finder participant to case"): - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, @@ -232,7 +242,7 @@ def demo_initialize_case( post_to_inbox_and_wait(client, vendor.as_id, add_participant_activity) with demo_check("Finder participant in case participant list"): final_case = log_case_state( - client, case.as_id, "after AddParticipantToCase" + client, case.as_id, "after AddParticipantToCaseActivity" ) if final_case and participant.as_id not in [ (p.as_id if hasattr(p, "as_id") else p) @@ -240,7 +250,7 @@ def demo_initialize_case( ]: raise ValueError( f"Participant '{participant.as_id}' not found in case " - "after AddParticipantToCase" + "after AddParticipantToCaseActivity" ) logger.info( diff --git a/vultron/demo/initialize_participant_demo.py b/vultron/demo/initialize_participant_demo.py index 8c609398..a49b54e2 100644 --- a/vultron/demo/initialize_participant_demo.py +++ b/vultron/demo/initialize_participant_demo.py @@ -45,19 +45,27 @@ from typing import Optional, Sequence, Tuple # Vultron imports -from vultron.as_vocab.activities.case import AddReportToCase, CreateCase -from vultron.as_vocab.activities.case_participant import ( - AddParticipantToCase, - CreateParticipant, +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, ) -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, + CreateParticipantActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( CoordinatorParticipant, FinderReporterParticipant, ) -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -97,7 +105,7 @@ def setup_case_precondition( content="An integer overflow vulnerability in the network stack.", name="Integer Overflow in Network Stack", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -105,7 +113,7 @@ def setup_case_precondition( post_to_inbox_and_wait(client, vendor.as_id, report_offer) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — integer overflow via crafted packet.", @@ -117,13 +125,13 @@ def setup_case_precondition( name="Integer Overflow Case — Network Stack", content="Tracking the integer overflow vulnerability in the network stack.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -182,7 +190,7 @@ def demo_initialize_participant( logger.info( f"Created coordinator participant: {logfmt(coordinator_participant)}" ) - create_coordinator_participant = CreateParticipant( + create_coordinator_participant = CreateParticipantActivity( actor=vendor.as_id, as_object=coordinator_participant, context=case.as_id, @@ -194,7 +202,7 @@ def demo_initialize_participant( verify_object_stored(client, coordinator_participant.as_id) with demo_step("Step 2: Vendor adds coordinator participant to case"): - add_coordinator_participant = AddParticipantToCase( + add_coordinator_participant = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=coordinator_participant.as_id, target=case.as_id, @@ -204,7 +212,9 @@ def demo_initialize_participant( ) with demo_check("Coordinator participant added to case"): updated_case = log_case_state( - client, case.as_id, "after coordinator AddParticipantToCase" + client, + case.as_id, + "after coordinator AddParticipantToCaseActivity", ) if updated_case and coordinator_participant.as_id not in [ (p.as_id if hasattr(p, "as_id") else p) @@ -212,7 +222,7 @@ def demo_initialize_participant( ]: raise ValueError( f"Coordinator participant '{coordinator_participant.as_id}'" - " not found in case after AddParticipantToCase" + " not found in case after AddParticipantToCaseActivity" ) logger.info("Coordinator added as participant to case") @@ -226,7 +236,7 @@ def demo_initialize_participant( logger.info( f"Created finder participant: {logfmt(finder_participant)}" ) - create_finder_participant = CreateParticipant( + create_finder_participant = CreateParticipantActivity( actor=vendor.as_id, as_object=finder_participant, context=case.as_id, @@ -236,7 +246,7 @@ def demo_initialize_participant( verify_object_stored(client, finder_participant.as_id) with demo_step("Step 4: Vendor adds finder participant to case"): - add_finder_participant = AddParticipantToCase( + add_finder_participant = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=finder_participant.as_id, target=case.as_id, @@ -244,7 +254,7 @@ def demo_initialize_participant( post_to_inbox_and_wait(client, vendor.as_id, add_finder_participant) with demo_check("Finder participant added to case"): final_case = log_case_state( - client, case.as_id, "after finder AddParticipantToCase" + client, case.as_id, "after finder AddParticipantToCaseActivity" ) if final_case and finder_participant.as_id not in [ (p.as_id if hasattr(p, "as_id") else p) @@ -252,7 +262,7 @@ def demo_initialize_participant( ]: raise ValueError( f"Finder participant '{finder_participant.as_id}' not found" - " in case after AddParticipantToCase" + " in case after AddParticipantToCaseActivity" ) logger.info("Finder added as participant to case") diff --git a/vultron/demo/invite_actor_demo.py b/vultron/demo/invite_actor_demo.py index e3cff375..24eeb9d0 100644 --- a/vultron/demo/invite_actor_demo.py +++ b/vultron/demo/invite_actor_demo.py @@ -52,22 +52,32 @@ from fastapi.encoders import jsonable_encoder # Vultron imports -from vultron.as_vocab.activities.case import AddReportToCase, CreateCase -from vultron.as_vocab.activities.case import ( - RmAcceptInviteToCase, - RmInviteToCase, - RmRejectInviteToCase, +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, ) -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.activities.case import ( + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, + RmRejectInviteToCaseActivity, +) +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( CoordinatorParticipant, FinderReporterParticipant, ) -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -101,7 +111,7 @@ def _setup_initialized_case( content="A remote code execution vulnerability in the web framework.", name="Remote Code Execution Vulnerability", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -110,7 +120,7 @@ def _setup_initialized_case( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — remote code execution via unsanitized input.", @@ -122,14 +132,14 @@ def _setup_initialized_case( name="RCE Case — Web Framework", content="Tracking the RCE vulnerability in the web framework.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) verify_object_stored(client, case.as_id) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -148,7 +158,7 @@ def _setup_initialized_case( post_to_inbox_and_wait(client, vendor.as_id, create_participant_activity) verify_object_stored(client, participant.as_id) - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, @@ -172,8 +182,8 @@ def demo_invite_actor_accept( Steps: 1. Setup: initialize case (report submitted + validated, case created, finder participant added) - 2. Vendor invites coordinator to case (RmInviteToCase → coordinator inbox) - 3. Coordinator accepts invitation (RmAcceptInviteToCase → vendor inbox) + 2. Vendor invites coordinator to case (RmInviteToCaseActivity → coordinator inbox) + 3. Coordinator accepts invitation (RmAcceptInviteToCaseActivity → vendor inbox) 4. Verify coordinator appears in case participant list This follows the accept branch in @@ -186,7 +196,7 @@ def demo_invite_actor_accept( case = _setup_initialized_case(client, finder, vendor) with demo_step("Step 2: Vendor invites coordinator to case"): - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( actor=vendor.as_id, object=coordinator.as_id, target=case.as_id, @@ -199,7 +209,7 @@ def demo_invite_actor_accept( with demo_step("Step 3: Coordinator accepts invitation"): # reference invite by ID so the handler can rehydrate it from the # datalayer with all fields intact - accept = RmAcceptInviteToCase( + accept = RmAcceptInviteToCaseActivity( actor=coordinator.as_id, object=invite.as_id, to=[vendor.as_id], @@ -246,8 +256,8 @@ def demo_invite_actor_reject( Steps: 1. Setup: initialize case (report submitted + validated, case created, finder participant added) - 2. Vendor invites coordinator to case (RmInviteToCase → coordinator inbox) - 3. Coordinator rejects invitation (RmRejectInviteToCase → vendor inbox) + 2. Vendor invites coordinator to case (RmInviteToCaseActivity → coordinator inbox) + 3. Coordinator rejects invitation (RmRejectInviteToCaseActivity → vendor inbox) 4. Verify coordinator does NOT appear in case participant list This follows the reject branch in @@ -263,7 +273,7 @@ def demo_invite_actor_reject( initial_count = len(initial_case.case_participants) if initial_case else 0 with demo_step("Step 2: Vendor invites coordinator to case"): - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( actor=vendor.as_id, object=coordinator.as_id, target=case.as_id, @@ -276,7 +286,7 @@ def demo_invite_actor_reject( with demo_step("Step 3: Coordinator rejects invitation"): # reference invite by ID so the handler can rehydrate it from the # datalayer with all fields intact - reject = RmRejectInviteToCase( + reject = RmRejectInviteToCaseActivity( actor=coordinator.as_id, object=invite.as_id, to=[vendor.as_id], diff --git a/vultron/demo/manage_case_demo.py b/vultron/demo/manage_case_demo.py index e75a3a57..0f6b102f 100644 --- a/vultron/demo/manage_case_demo.py +++ b/vultron/demo/manage_case_demo.py @@ -27,18 +27,18 @@ docs/howto/activitypub/activities/manage_case.md Key activities demonstrated: -- RmSubmitReport (as:Offer) — finder submits a report to vendor -- RmValidateReport (as:Accept) — vendor validates the report -- RmInvalidateReport (as:TentativeReject) — vendor invalidates the report -- CreateCase (as:Create) — vendor creates a VulnerabilityCase -- RmEngageCase (as:Join) — actor actively engages the case (RM → ACCEPTED) -- RmDeferCase (as:Ignore) — actor defers the case (RM → DEFERRED) -- RmCloseCase (as:Leave) — actor closes the case (RM → CLOSED) -- RmCloseReport (as:Reject) — vendor closes an invalid report +- RmSubmitReportActivity (as:Offer) — finder submits a report to vendor +- RmValidateReportActivity (as:Accept) — vendor validates the report +- RmInvalidateReportActivity (as:TentativeReject) — vendor invalidates the report +- CreateCaseActivity (as:Create) — vendor creates a VulnerabilityCase +- RmEngageCaseActivity (as:Join) — actor actively engages the case (RM → ACCEPTED) +- RmDeferCaseActivity (as:Ignore) — actor defers the case (RM → DEFERRED) +- RmCloseCaseActivity (as:Leave) — actor closes the case (RM → CLOSED) +- RmCloseReportActivity (as:Reject) — vendor closes an invalid report Note on re-engagement: Per implementation notes, re-engaging a deferred case is done by sending -another RmEngageCase (as:Join) activity. There is no separate RmReEngageCase +another RmEngageCaseActivity (as:Join) activity. There is no separate RmReEngageCase activity; the RM state machine allows a direct DEFERRED → ACCEPTED transition. When run as a script, this module will: @@ -57,24 +57,28 @@ from typing import Optional, Sequence, Tuple # Vultron imports -from vultron.as_vocab.activities.case import ( - AddReportToCase, - CreateCase, - RmCloseCase, - RmDeferCase, - RmEngageCase, +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, + RmCloseCaseActivity, + RmDeferCaseActivity, + RmEngageCaseActivity, ) -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.report import ( - RmCloseReport, - RmInvalidateReport, - RmSubmitReport, - RmValidateReport, +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmCloseReportActivity, + RmInvalidateReportActivity, + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import VendorParticipant +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, ) -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import VendorParticipant -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -112,7 +116,7 @@ def setup_report_and_case( content=report_content, name=report_name, ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -120,7 +124,7 @@ def setup_report_and_case( post_to_inbox_and_wait(client, vendor.as_id, report_offer) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — vulnerability verified.", @@ -132,7 +136,7 @@ def setup_report_and_case( name=case_name, content=case_content, ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) @@ -142,7 +146,9 @@ def setup_report_and_case( attributed_to=vendor.as_id, context=case.as_id, ) - from vultron.as_vocab.base.objects.activities.transitive import as_Create + from vultron.wire.as2.vocab.base.objects.activities.transitive import ( + as_Create, + ) create_vendor_participant = as_Create( actor=vendor.as_id, @@ -151,14 +157,14 @@ def setup_report_and_case( ) post_to_inbox_and_wait(client, vendor.as_id, create_vendor_participant) - add_vendor_participant = AddParticipantToCase( + add_vendor_participant = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=vendor_participant.as_id, target=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, add_vendor_participant) - add_report = AddReportToCase( + add_report = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -178,8 +184,8 @@ def demo_engage_path( Workflow steps: 1. Finder submits report; vendor validates it 2. Vendor creates VulnerabilityCase; adds vendor participant and report - 3. Vendor engages the case (RmEngageCase — RM → ACCEPTED) - 4. Vendor closes the case (RmCloseCase — RM → CLOSED) + 3. Vendor engages the case (RmEngageCaseActivity — RM → ACCEPTED) + 4. Vendor closes the case (RmCloseCaseActivity — RM → CLOSED) """ logger.info("=" * 80) logger.info("DEMO 1: Engage Path (submit → validate → engage → close)") @@ -211,22 +217,22 @@ def demo_engage_path( f"Report '{report.as_id}' not linked to case after setup" ) - with demo_step("Step 3: Vendor engages the case (RmEngageCase)"): - engage = RmEngageCase( + with demo_step("Step 3: Vendor engages the case (RmEngageCaseActivity)"): + engage = RmEngageCaseActivity( actor=vendor.as_id, as_object=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, engage) - with demo_check("RmEngageCase activity stored"): + with demo_check("RmEngageCaseActivity activity stored"): verify_object_stored(client, engage.as_id) - with demo_step("Step 4: Vendor closes the case (RmCloseCase)"): - close = RmCloseCase( + with demo_step("Step 4: Vendor closes the case (RmCloseCaseActivity)"): + close = RmCloseCaseActivity( actor=vendor.as_id, as_object=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, close) - with demo_check("RmCloseCase activity stored"): + with demo_check("RmCloseCaseActivity activity stored"): verify_object_stored(client, close.as_id) logger.info("✅ DEMO 1 COMPLETE: Case engaged then closed.") @@ -240,15 +246,15 @@ def demo_defer_reengage_path( submit → validate → create_case → defer → re-engage → close. Per implementation notes, re-engaging a deferred case uses another - RmEngageCase (as:Join) — there is no separate RmReEngageCase activity. + RmEngageCaseActivity (as:Join) — there is no separate RmReEngageCase activity. The RM state machine allows a direct DEFERRED → ACCEPTED transition. Workflow steps: 1. Finder submits report; vendor validates it 2. Vendor creates case with vendor participant and linked report - 3. Vendor defers the case (RmDeferCase — RM → DEFERRED) - 4. Vendor re-engages via RmEngageCase (RM → ACCEPTED) - 5. Vendor closes the case (RmCloseCase — RM → CLOSED) + 3. Vendor defers the case (RmDeferCaseActivity — RM → DEFERRED) + 4. Vendor re-engages via RmEngageCaseActivity (RM → ACCEPTED) + 5. Vendor closes the case (RmCloseCaseActivity — RM → CLOSED) """ logger.info("=" * 80) logger.info( @@ -276,39 +282,39 @@ def demo_defer_reengage_path( with demo_check("Case created and report linked"): log_case_state(client, case.as_id, "after setup") - with demo_step("Step 3: Vendor defers the case (RmDeferCase)"): - defer = RmDeferCase( + with demo_step("Step 3: Vendor defers the case (RmDeferCaseActivity)"): + defer = RmDeferCaseActivity( actor=vendor.as_id, as_object=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, defer) - with demo_check("RmDeferCase activity stored"): + with demo_check("RmDeferCaseActivity activity stored"): verify_object_stored(client, defer.as_id) logger.info("Case is now deferred (RM → DEFERRED).") with demo_step( - "Step 4: Vendor re-engages the case (RmEngageCase from DEFERRED)" + "Step 4: Vendor re-engages the case (RmEngageCaseActivity from DEFERRED)" ): - reengage = RmEngageCase( + reengage = RmEngageCaseActivity( actor=vendor.as_id, as_object=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, reengage) - with demo_check("RmEngageCase (re-engage) activity stored"): + with demo_check("RmEngageCaseActivity (re-engage) activity stored"): verify_object_stored(client, reengage.as_id) logger.info( - "Case re-engaged via RmEngageCase (RM → ACCEPTED). " - "Note: re-engagement uses the same RmEngageCase activity as initial " + "Case re-engaged via RmEngageCaseActivity (RM → ACCEPTED). " + "Note: re-engagement uses the same RmEngageCaseActivity activity as initial " "engagement; there is no separate RmReEngageCase activity." ) - with demo_step("Step 5: Vendor closes the case (RmCloseCase)"): - close = RmCloseCase( + with demo_step("Step 5: Vendor closes the case (RmCloseCaseActivity)"): + close = RmCloseCaseActivity( actor=vendor.as_id, as_object=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, close) - with demo_check("RmCloseCase activity stored"): + with demo_check("RmCloseCaseActivity activity stored"): verify_object_stored(client, close.as_id) logger.info("✅ DEMO 2 COMPLETE: Case deferred, re-engaged, then closed.") @@ -325,8 +331,8 @@ def demo_invalidate_path( Workflow steps: 1. Finder submits a report to the vendor - 2. Vendor invalidates the report (RmInvalidateReport — RM → INVALID) - 3. Vendor closes the report (RmCloseReport — RM → CLOSED) + 2. Vendor invalidates the report (RmInvalidateReportActivity — RM → INVALID) + 3. Vendor closes the report (RmCloseReportActivity — RM → CLOSED) """ logger.info("=" * 80) logger.info("DEMO 3: Invalidate Path (submit → invalidate → close_report)") @@ -340,7 +346,7 @@ def demo_invalidate_path( name="Alleged Username Enumeration", ) logger.info(f"Created report: {logfmt(report)}") - offer = RmSubmitReport( + offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -351,12 +357,12 @@ def demo_invalidate_path( verify_object_stored(client, offer.as_id) with demo_step( - "Step 2: Vendor invalidates the report (RmInvalidateReport)" + "Step 2: Vendor invalidates the report (RmInvalidateReportActivity)" ): stored_offer = get_offer_from_datalayer( client, vendor.as_id, offer.as_id ) - invalidate = RmInvalidateReport( + invalidate = RmInvalidateReportActivity( actor=vendor.as_id, object=stored_offer.as_id, content=( @@ -365,17 +371,17 @@ def demo_invalidate_path( ), ) post_to_inbox_and_wait(client, vendor.as_id, invalidate) - with demo_check("RmInvalidateReport activity stored"): + with demo_check("RmInvalidateReportActivity activity stored"): verify_object_stored(client, invalidate.as_id) - with demo_step("Step 3: Vendor closes the report (RmCloseReport)"): - close_report = RmCloseReport( + with demo_step("Step 3: Vendor closes the report (RmCloseReportActivity)"): + close_report = RmCloseReportActivity( actor=vendor.as_id, object=stored_offer.as_id, content="Report closed — assessed as not a valid vulnerability.", ) post_to_inbox_and_wait(client, vendor.as_id, close_report) - with demo_check("RmCloseReport activity stored"): + with demo_check("RmCloseReportActivity activity stored"): verify_object_stored(client, close_report.as_id) logger.info( diff --git a/vultron/demo/manage_embargo_demo.py b/vultron/demo/manage_embargo_demo.py index 6b0382d1..8d6ff8ce 100644 --- a/vultron/demo/manage_embargo_demo.py +++ b/vultron/demo/manage_embargo_demo.py @@ -47,31 +47,38 @@ from datetime import datetime, timedelta from typing import Optional, Sequence, Tuple -from vultron.as_vocab.activities.case import ( - AddReportToCase, - CreateCase, - RmAcceptInviteToCase, - RmInviteToCase, +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, ) -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.embargo import ( - ActivateEmbargo, - AnnounceEmbargo, - EmAcceptEmbargo, - EmProposeEmbargo, - EmRejectEmbargo, - RemoveEmbargoFromCase, +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, ) -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.activities.embargo import ( + ActivateEmbargoActivity, + AnnounceEmbargoActivity, + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, + EmRejectEmbargoActivity, + RemoveEmbargoFromCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( CoordinatorParticipant, FinderReporterParticipant, ) -from vultron.as_vocab.objects.embargo_event import EmbargoEvent -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -132,7 +139,7 @@ def _setup_two_participant_case( content="A heap-overflow vulnerability in the authentication library.", name="Heap Overflow in Auth Library", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -141,7 +148,7 @@ def _setup_two_participant_case( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — heap overflow via malformed auth token.", @@ -153,14 +160,14 @@ def _setup_two_participant_case( name="Heap Overflow — Auth Library", content="Tracking the heap-overflow vulnerability in the auth library.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) verify_object_stored(client, case.as_id) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -179,14 +186,14 @@ def _setup_two_participant_case( post_to_inbox_and_wait(client, vendor.as_id, create_participant_activity) verify_object_stored(client, participant.as_id) - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, add_participant_activity) - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( actor=vendor.as_id, object=coordinator.as_id, target=case.as_id, @@ -195,7 +202,7 @@ def _setup_two_participant_case( ) post_to_inbox_and_wait(client, coordinator.as_id, invite) - accept = RmAcceptInviteToCase( + accept = RmAcceptInviteToCaseActivity( actor=coordinator.as_id, object=invite.as_id, to=[vendor.as_id], @@ -221,12 +228,12 @@ def demo_activate_then_terminate( Steps: 1. Setup: initialize case with two participants (vendor + coordinator) - 2. Coordinator proposes embargo (EmProposeEmbargo → vendor inbox) - 3. Vendor accepts embargo (EmAcceptEmbargo → coordinator inbox) - 4. Vendor activates embargo on case (ActivateEmbargo → vendor inbox) + 2. Coordinator proposes embargo (EmProposeEmbargoActivity → vendor inbox) + 3. Vendor accepts embargo (EmAcceptEmbargoActivity → coordinator inbox) + 4. Vendor activates embargo on case (ActivateEmbargoActivity → vendor inbox) 5. Vendor announces embargo to participants 6. Verify case has active embargo - 7. Vendor terminates (removes) the embargo (RemoveEmbargoFromCase) + 7. Vendor terminates (removes) the embargo (RemoveEmbargoFromCaseActivity) 8. Verify case has no active embargo This follows the activate → terminate branch in @@ -247,7 +254,7 @@ def demo_activate_then_terminate( ) post_to_inbox_and_wait(client, vendor.as_id, create_embargo) - proposal = EmProposeEmbargo( + proposal = EmProposeEmbargoActivity( id=f"{case.as_id}/embargo_proposals/activate-1", actor=coordinator.as_id, object=embargo, @@ -259,7 +266,7 @@ def demo_activate_then_terminate( post_to_inbox_and_wait(client, vendor.as_id, proposal) with demo_step("Step 3: Vendor accepts embargo proposal"): - accept = EmAcceptEmbargo( + accept = EmAcceptEmbargoActivity( actor=vendor.as_id, object=proposal.as_id, context=case.as_id, @@ -270,7 +277,7 @@ def demo_activate_then_terminate( post_to_inbox_and_wait(client, coordinator.as_id, accept) with demo_step("Step 4: Vendor activates embargo on case"): - activate = ActivateEmbargo( + activate = ActivateEmbargoActivity( actor=vendor.as_id, object=embargo.as_id, target=case.as_id, @@ -281,7 +288,7 @@ def demo_activate_then_terminate( post_to_inbox_and_wait(client, vendor.as_id, activate) with demo_step("Step 5: Vendor announces embargo to participants"): - announce = AnnounceEmbargo( + announce = AnnounceEmbargoActivity( actor=vendor.as_id, object=embargo.as_id, context=case.as_id, @@ -307,7 +314,7 @@ def demo_activate_then_terminate( ) with demo_step("Step 7: Vendor terminates (removes) the active embargo"): - remove = RemoveEmbargoFromCase( + remove = RemoveEmbargoFromCaseActivity( actor=vendor.as_id, object=embargo.as_id, origin=case.as_id, @@ -378,7 +385,7 @@ def demo_reject_then_repropose( ) post_to_inbox_and_wait(client, vendor.as_id, create_embargo_v1) - proposal_v1 = EmProposeEmbargo( + proposal_v1 = EmProposeEmbargoActivity( id=f"{case.as_id}/embargo_proposals/reject-1", actor=coordinator.as_id, object=embargo_v1, @@ -390,7 +397,7 @@ def demo_reject_then_repropose( post_to_inbox_and_wait(client, vendor.as_id, proposal_v1) with demo_step("Step 3: Vendor rejects first embargo proposal"): - reject = EmRejectEmbargo( + reject = EmRejectEmbargoActivity( actor=vendor.as_id, object=proposal_v1.as_id, context=case.as_id, @@ -430,7 +437,7 @@ def demo_reject_then_repropose( ) post_to_inbox_and_wait(client, vendor.as_id, create_embargo_v2) - proposal_v2 = EmProposeEmbargo( + proposal_v2 = EmProposeEmbargoActivity( id=f"{case.as_id}/embargo_proposals/reject-2", actor=coordinator.as_id, object=embargo_v2, @@ -442,7 +449,7 @@ def demo_reject_then_repropose( post_to_inbox_and_wait(client, vendor.as_id, proposal_v2) with demo_step("Step 6: Vendor accepts revised embargo proposal"): - accept_v2 = EmAcceptEmbargo( + accept_v2 = EmAcceptEmbargoActivity( actor=vendor.as_id, object=proposal_v2.as_id, context=case.as_id, @@ -453,7 +460,7 @@ def demo_reject_then_repropose( post_to_inbox_and_wait(client, coordinator.as_id, accept_v2) with demo_step("Step 7: Vendor activates revised embargo"): - activate_v2 = ActivateEmbargo( + activate_v2 = ActivateEmbargoActivity( actor=vendor.as_id, object=embargo_v2.as_id, target=case.as_id, diff --git a/vultron/demo/manage_participants_demo.py b/vultron/demo/manage_participants_demo.py index 18647cce..aef3ca51 100644 --- a/vultron/demo/manage_participants_demo.py +++ b/vultron/demo/manage_participants_demo.py @@ -44,28 +44,36 @@ import sys from typing import Optional, Sequence, Tuple -from vultron.as_vocab.activities.case import AddReportToCase, CreateCase -from vultron.as_vocab.activities.case_participant import ( - AddParticipantToCase, - AddStatusToParticipant, - CreateParticipant, - CreateStatusForParticipant, - RemoveParticipantFromCase, +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, ) -from vultron.as_vocab.activities.case import ( - RmAcceptInviteToCase, - RmInviteToCase, - RmRejectInviteToCase, +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, + AddStatusToParticipantActivity, + CreateParticipantActivity, + CreateStatusForParticipantActivity, + RemoveParticipantFromCaseActivity, ) -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.activities.case import ( + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, + RmRejectInviteToCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( CoordinatorParticipant, VendorParticipant, ) -from vultron.as_vocab.objects.case_status import ParticipantStatus -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.bt.report_management.states import RM from vultron.case_states.states import CS_vfd from vultron.demo.utils import ( @@ -108,7 +116,7 @@ def _setup_case_with_vendor( content="A use-after-free vulnerability in the memory allocator.", name="Use-After-Free in Memory Allocator", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -117,7 +125,7 @@ def _setup_case_with_vendor( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — use-after-free via crafted allocation sequence.", @@ -129,7 +137,7 @@ def _setup_case_with_vendor( name="UAF Case — Memory Allocator", content="Tracking the use-after-free in the memory allocator.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) @@ -140,21 +148,21 @@ def _setup_case_with_vendor( attributed_to=vendor.as_id, context=case.as_id, ) - create_vendor_participant = CreateParticipant( + create_vendor_participant = CreateParticipantActivity( actor=vendor.as_id, as_object=vendor_participant, context=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, create_vendor_participant) - add_vendor_participant = AddParticipantToCase( + add_vendor_participant = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=vendor_participant.as_id, target=case.as_id, ) post_to_inbox_and_wait(client, vendor.as_id, add_vendor_participant) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -178,13 +186,13 @@ def demo_manage_participants_accept( Steps: 1. Setup: initialize case (report submitted + validated, case created, vendor participant added) - 2. Vendor invites coordinator to case (RmInviteToCase) - 3. Coordinator accepts invitation (RmAcceptInviteToCase) - 4. Vendor creates coordinator participant (CreateParticipant) - 5. Vendor adds coordinator participant to case (AddParticipantToCase) - 6. Coordinator creates a ParticipantStatus (CreateStatusForParticipant) - 7. Coordinator adds the status to their participant (AddStatusToParticipant) - 8. Vendor removes coordinator participant from case (RemoveParticipantFromCase) + 2. Vendor invites coordinator to case (RmInviteToCaseActivity) + 3. Coordinator accepts invitation (RmAcceptInviteToCaseActivity) + 4. Vendor creates coordinator participant (CreateParticipantActivity) + 5. Vendor adds coordinator participant to case (AddParticipantToCaseActivity) + 6. Coordinator creates a ParticipantStatus (CreateStatusForParticipantActivity) + 7. Coordinator adds the status to their participant (AddStatusToParticipantActivity) + 8. Vendor removes coordinator participant from case (RemoveParticipantFromCaseActivity) 9. Verify coordinator no longer in case participant list This follows the accept branch in @@ -197,7 +205,7 @@ def demo_manage_participants_accept( case = _setup_case_with_vendor(client, finder, vendor) with demo_step("Step 2: Vendor invites coordinator to case"): - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( actor=vendor.as_id, object=coordinator.as_id, target=case.as_id, @@ -208,7 +216,7 @@ def demo_manage_participants_accept( post_to_inbox_and_wait(client, coordinator.as_id, invite) with demo_step("Step 3: Coordinator accepts invitation"): - accept = RmAcceptInviteToCase( + accept = RmAcceptInviteToCaseActivity( actor=coordinator.as_id, object=invite.as_id, to=[vendor.as_id], @@ -222,7 +230,7 @@ def demo_manage_participants_accept( attributed_to=coordinator.as_id, context=case.as_id, ) - create_participant = CreateParticipant( + create_participant = CreateParticipantActivity( actor=vendor.as_id, as_object=coordinator_participant, context=case.as_id, @@ -232,7 +240,7 @@ def demo_manage_participants_accept( verify_object_stored(client, coordinator_participant.as_id) with demo_step("Step 5: Vendor adds coordinator participant to case"): - add_participant = AddParticipantToCase( + add_participant = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=coordinator_participant.as_id, target=case.as_id, @@ -240,7 +248,7 @@ def demo_manage_participants_accept( post_to_inbox_and_wait(client, vendor.as_id, add_participant) with demo_check("Coordinator in case participant list"): updated_case = log_case_state( - client, case.as_id, "after AddParticipantToCase" + client, case.as_id, "after AddParticipantToCaseActivity" ) if updated_case is None: raise ValueError( @@ -263,7 +271,7 @@ def demo_manage_participants_accept( vfd_state=CS_vfd.vfd, attributed_to=coordinator.as_id, ) - create_status = CreateStatusForParticipant( + create_status = CreateStatusForParticipantActivity( actor=coordinator.as_id, object=participant_status, target=coordinator_participant.as_id, @@ -275,17 +283,19 @@ def demo_manage_participants_accept( with demo_step( "Step 7: Coordinator adds ParticipantStatus to their participant" ): - add_status = AddStatusToParticipant( + add_status = AddStatusToParticipantActivity( actor=coordinator.as_id, object=participant_status, target=coordinator_participant.as_id, ) post_to_inbox_and_wait(client, coordinator.as_id, add_status) with demo_check("Case state after status update"): - log_case_state(client, case.as_id, "after AddStatusToParticipant") + log_case_state( + client, case.as_id, "after AddStatusToParticipantActivity" + ) with demo_step("Step 8: Vendor removes coordinator from case"): - remove_participant = RemoveParticipantFromCase( + remove_participant = RemoveParticipantFromCaseActivity( actor=vendor.as_id, as_object=coordinator_participant.as_id, target=case.as_id, @@ -295,7 +305,7 @@ def demo_manage_participants_accept( with demo_step("Step 9: Verify coordinator no longer in case"): with demo_check("Coordinator absent from case participant list"): final_case = log_case_state( - client, case.as_id, "after RemoveParticipantFromCase" + client, case.as_id, "after RemoveParticipantFromCaseActivity" ) if final_case is None: raise ValueError("Could not retrieve case after remove") @@ -330,8 +340,8 @@ def demo_manage_participants_reject( Steps: 1. Setup: initialize case (report submitted + validated, case created, vendor participant added) - 2. Vendor invites coordinator to case (RmInviteToCase) - 3. Coordinator rejects invitation (RmRejectInviteToCase) + 2. Vendor invites coordinator to case (RmInviteToCaseActivity) + 3. Coordinator rejects invitation (RmRejectInviteToCaseActivity) 4. Verify coordinator does NOT appear in case participant list This follows the reject branch in @@ -347,7 +357,7 @@ def demo_manage_participants_reject( initial_count = len(initial_case.case_participants) if initial_case else 0 with demo_step("Step 2: Vendor invites coordinator to case"): - invite = RmInviteToCase( + invite = RmInviteToCaseActivity( actor=vendor.as_id, object=coordinator.as_id, target=case.as_id, @@ -358,7 +368,7 @@ def demo_manage_participants_reject( post_to_inbox_and_wait(client, coordinator.as_id, invite) with demo_step("Step 3: Coordinator rejects invitation"): - reject = RmRejectInviteToCase( + reject = RmRejectInviteToCaseActivity( actor=coordinator.as_id, object=invite.as_id, to=[vendor.as_id], diff --git a/vultron/demo/receive_report_demo.py b/vultron/demo/receive_report_demo.py index 9582ac01..a8119f44 100644 --- a/vultron/demo/receive_report_demo.py +++ b/vultron/demo/receive_report_demo.py @@ -17,9 +17,9 @@ Demonstrates the workflow for receiving and processing vulnerability reports via the Vultron API. This demo script showcases three different outcomes when processing vulnerability reports: -1. Validate Report: RmValidateReport (Accept) - creates a case -2. Invalidate Report: RmInvalidateReport (TentativeReject) - holds for reconsideration -3. Invalidate and Close Report: RmInvalidateReport + RmCloseReport - rejects and closes +1. Validate Report: RmValidateReportActivity (Accept) - creates a case +2. Invalidate Report: RmInvalidateReportActivity (TentativeReject) - holds for reconsideration +3. Invalidate and Close Report: RmInvalidateReportActivity + RmCloseReportActivity - rejects and closes This demo uses direct inbox-to-inbox communication between actors, per the Vultron prototype design (see docs/reference/inbox_handler.md). Actors post activities directly to each other's @@ -55,17 +55,19 @@ # Vultron imports from vultron.api.v2.data.utils import parse_id -from vultron.as_vocab.activities.case import CreateCase -from vultron.as_vocab.activities.report import ( - RmCloseReport, - RmInvalidateReport, - RmSubmitReport, - RmValidateReport, +from vultron.wire.as2.vocab.activities.case import CreateCaseActivity +from vultron.wire.as2.vocab.activities.report import ( + RmCloseReportActivity, + RmInvalidateReportActivity, + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, ) -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -83,9 +85,9 @@ logger = logging.getLogger(__name__) -def make_submit_offer(finder, vendor, report) -> RmSubmitReport: - """Build an ``RmSubmitReport`` offer from the finder to the vendor.""" - offer = RmSubmitReport( +def make_submit_offer(finder, vendor, report) -> RmSubmitReportActivity: + """Build an ``RmSubmitReportActivity`` offer from the finder to the vendor.""" + offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -257,14 +259,14 @@ def demo_validate_report( """ Demonstrates the workflow where a vendor validates a report and creates a case. - Uses RmValidateReport (Accept) activity, followed by vendor posting - a CreateCase activity to the finder's inbox. + Uses RmValidateReportActivity (Accept) activity, followed by vendor posting + a CreateCaseActivity activity to the finder's inbox. This follows the "Receiver Accepts Offered Report" sequence diagram from docs/howto/activitypub/activities/report_vulnerability.md. Note: This demo uses direct inbox-to-inbox communication. The vendor posts - the CreateCase activity directly to the finder's inbox rather than using outbox + the CreateCaseActivity activity directly to the finder's inbox rather than using outbox processing, per the Vultron prototype design (see docs/reference/inbox_handler.md). """ logger.info("=" * 80) @@ -290,7 +292,7 @@ def demo_validate_report( offer = get_offer_from_datalayer( client, vendor.as_id, report_offer.as_id ) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Validating the report as legitimate. Creating case.", @@ -308,22 +310,22 @@ def demo_validate_report( if not case_data: logger.error("Could not find case related to this report.") raise ValueError("Could not find case related to this report.") - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case_data.as_id, to=[finder.as_id], content="Case created for your vulnerability report.", ) post_to_inbox_and_wait(client, finder.as_id, create_case_activity) - with demo_check("CreateCase activity in finder's inbox"): + with demo_check("CreateCaseActivity activity in finder's inbox"): if not verify_activity_in_inbox( client, finder.as_id, create_case_activity.as_id ): logger.error( - "CreateCase activity not found in finder's inbox." + "CreateCaseActivity activity not found in finder's inbox." ) raise ValueError( - "CreateCase activity not found in finder's inbox." + "CreateCaseActivity activity not found in finder's inbox." ) logger.info( @@ -337,7 +339,7 @@ def demo_invalidate_report( """ Demonstrates the workflow where a vendor invalidates a report. - Uses RmInvalidateReport (TentativeReject) activity, followed by vendor posting + Uses RmInvalidateReportActivity (TentativeReject) activity, followed by vendor posting the response directly to the finder's inbox. This follows the "Receiver Invalidates and Holds Offered Report" sequence diagram from @@ -372,7 +374,7 @@ def demo_invalidate_report( offer = get_offer_from_datalayer( client, vendor.as_id, report_offer.as_id ) - invalidate_activity = RmInvalidateReport( + invalidate_activity = RmInvalidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Invalidating the report - needs more investigation before accepting.", @@ -387,7 +389,7 @@ def demo_invalidate_report( ) with demo_step("Step 3: Vendor notifies finder of invalidation"): - invalidate_response_to_finder = RmInvalidateReport( + invalidate_response_to_finder = RmInvalidateReportActivity( actor=vendor.as_id, object=offer.as_id, to=[finder.as_id], @@ -418,7 +420,7 @@ def demo_invalidate_and_close_report( """ Demonstrates the workflow where a vendor invalidates a report and closes it. - Uses RmInvalidateReport (TentativeReject) followed by RmCloseReport (Reject) activities, + Uses RmInvalidateReportActivity (TentativeReject) followed by RmCloseReportActivity (Reject) activities, with responses posted directly to the finder's inbox. This follows the "Receiver Invalidates and Closes Offered Report" sequence diagram from @@ -451,13 +453,13 @@ def demo_invalidate_and_close_report( offer = get_offer_from_datalayer( client, vendor.as_id, report_offer.as_id ) - invalidate_activity = RmInvalidateReport( + invalidate_activity = RmInvalidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Invalidating the report - this is a false positive.", ) post_to_inbox_and_wait(client, vendor.as_id, invalidate_activity) - close_activity = RmCloseReport( + close_activity = RmCloseReportActivity( actor=vendor.as_id, object=offer.as_id, content="Closing the report as invalid.", @@ -478,7 +480,7 @@ def demo_invalidate_and_close_report( with demo_step( "Step 3: Vendor notifies finder of invalidation and closure" ): - invalidate_response_to_finder = RmInvalidateReport( + invalidate_response_to_finder = RmInvalidateReportActivity( actor=vendor.as_id, object=offer.as_id, to=[finder.as_id], @@ -489,7 +491,7 @@ def demo_invalidate_and_close_report( finder.as_id, invalidate_response_to_finder, ) - close_response_to_finder = RmCloseReport( + close_response_to_finder = RmCloseReportActivity( actor=vendor.as_id, object=offer.as_id, to=[finder.as_id], diff --git a/vultron/demo/status_updates_demo.py b/vultron/demo/status_updates_demo.py index 03cb41a1..1d34385d 100644 --- a/vultron/demo/status_updates_demo.py +++ b/vultron/demo/status_updates_demo.py @@ -41,32 +41,40 @@ import sys from typing import Optional, Sequence, Tuple -from vultron.as_vocab.activities.case import ( - AddNoteToCase, - AddReportToCase, - AddStatusToCase, - CreateCase, - CreateCaseStatus, +from vultron.wire.as2.vocab.activities.case import ( + AddNoteToCaseActivity, + AddReportToCaseActivity, + AddStatusToCaseActivity, + CreateCaseActivity, + CreateCaseStatusActivity, ) -from vultron.as_vocab.activities.case_participant import ( - AddParticipantToCase, - AddStatusToParticipant, - CreateStatusForParticipant, +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, + AddStatusToParticipantActivity, + CreateStatusForParticipantActivity, ) -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Create, as_Remove, ) -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.object_types import as_Note -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.object_types import as_Note +from vultron.wire.as2.vocab.objects.case_participant import ( CaseParticipant, FinderReporterParticipant, ) -from vultron.as_vocab.objects.case_status import CaseStatus, ParticipantStatus -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.objects.case_status import ( + CaseStatus, + ParticipantStatus, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.bt.embargo_management.states import EM from vultron.bt.report_management.states import RM from vultron.case_states.states import CS_pxa, CS_vfd @@ -109,7 +117,7 @@ def _setup_initialized_case( content="A heap buffer overflow in the image parsing library.", name="Heap Buffer Overflow in Image Parser", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -118,7 +126,7 @@ def _setup_initialized_case( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — heap buffer overflow via malformed image input.", @@ -130,14 +138,14 @@ def _setup_initialized_case( name="Heap Overflow Case — Image Parser", content="Tracking the heap buffer overflow in the image parsing library.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) verify_object_stored(client, case.as_id) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -156,7 +164,7 @@ def _setup_initialized_case( post_to_inbox_and_wait(client, vendor.as_id, create_participant_activity) verify_object_stored(client, participant.as_id) - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, @@ -207,7 +215,7 @@ def demo_notes_workflow( verify_object_stored(client, note.as_id) with demo_step("Step 2: Vendor adds note to case"): - add_note_activity = AddNoteToCase( + add_note_activity = AddNoteToCaseActivity( actor=vendor.as_id, object=note, target=case.as_id, @@ -215,7 +223,7 @@ def demo_notes_workflow( post_to_inbox_and_wait(client, vendor.as_id, add_note_activity) with demo_check("Note present in case"): updated_case = log_case_state( - client, case.as_id, "after AddNoteToCase" + client, case.as_id, "after AddNoteToCaseActivity" ) if updated_case: note_ids = [ @@ -224,7 +232,7 @@ def demo_notes_workflow( ] if note.as_id not in note_ids: raise ValueError( - f"Note '{note.as_id}' not found in case after AddNoteToCase" + f"Note '{note.as_id}' not found in case after AddNoteToCaseActivity" ) with demo_step("Step 3: Vendor removes note from case"): @@ -277,7 +285,7 @@ def demo_status_workflow( em_state=EM.NO_EMBARGO, pxa_state=CS_pxa.pxa, ) - create_status_activity = CreateCaseStatus( + create_status_activity = CreateCaseStatusActivity( actor=vendor.as_id, object=case_status, context=case.as_id, @@ -287,7 +295,7 @@ def demo_status_workflow( verify_object_stored(client, case_status.as_id) with demo_step("Step 2: Vendor adds CaseStatus to case"): - add_status_activity = AddStatusToCase( + add_status_activity = AddStatusToCaseActivity( actor=vendor.as_id, object=case_status, target=case.as_id, @@ -295,7 +303,7 @@ def demo_status_workflow( post_to_inbox_and_wait(client, vendor.as_id, add_status_activity) with demo_check("CaseStatus present in case"): updated_case = log_case_state( - client, case.as_id, "after AddStatusToCase" + client, case.as_id, "after AddStatusToCaseActivity" ) if updated_case: status_ids = [ @@ -305,7 +313,7 @@ def demo_status_workflow( if case_status.as_id not in status_ids: raise ValueError( f"CaseStatus '{case_status.as_id}' not found in case " - "after AddStatusToCase" + "after AddStatusToCaseActivity" ) with demo_step("Step 3: Vendor creates ParticipantStatus"): @@ -316,7 +324,7 @@ def demo_status_workflow( attributed_to=finder.as_id, case_status=case_status, ) - create_pstatus_activity = CreateStatusForParticipant( + create_pstatus_activity = CreateStatusForParticipantActivity( actor=vendor.as_id, object=participant_status, ) @@ -325,7 +333,7 @@ def demo_status_workflow( verify_object_stored(client, participant_status.as_id) with demo_step("Step 4: Vendor adds ParticipantStatus to participant"): - add_pstatus_activity = AddStatusToParticipant( + add_pstatus_activity = AddStatusToParticipantActivity( actor=vendor.as_id, object=participant_status, target=participant.as_id, diff --git a/vultron/demo/suggest_actor_demo.py b/vultron/demo/suggest_actor_demo.py index 1bfd821f..8866e18c 100644 --- a/vultron/demo/suggest_actor_demo.py +++ b/vultron/demo/suggest_actor_demo.py @@ -49,19 +49,31 @@ from typing import Optional, Sequence, Tuple # Vultron imports -from vultron.as_vocab.activities.actor import ( - AcceptActorRecommendation, - RecommendActor, - RejectActorRecommendation, +from vultron.wire.as2.vocab.activities.actor import ( + AcceptActorRecommendationActivity, + RecommendActorActivity, + RejectActorRecommendationActivity, +) +from vultron.wire.as2.vocab.activities.case import ( + AddReportToCaseActivity, + CreateCaseActivity, +) +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( + FinderReporterParticipant, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, ) -from vultron.as_vocab.activities.case import AddReportToCase, CreateCase -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import FinderReporterParticipant -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport from vultron.demo.utils import ( BASE_URL, DataLayerClient, @@ -95,7 +107,7 @@ def _setup_initialized_case( content="A remote code execution vulnerability in the web framework.", name="Remote Code Execution Vulnerability", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -104,7 +116,7 @@ def _setup_initialized_case( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — remote code execution via unsanitized input.", @@ -116,14 +128,14 @@ def _setup_initialized_case( name="RCE Case — Web Framework", content="Tracking the RCE vulnerability in the web framework.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) verify_object_stored(client, case.as_id) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -142,7 +154,7 @@ def _setup_initialized_case( post_to_inbox_and_wait(client, vendor.as_id, create_participant_activity) verify_object_stored(client, participant.as_id) - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, @@ -180,7 +192,7 @@ def demo_suggest_actor_accept( case = _setup_initialized_case(client, finder, vendor) with demo_step("Step 2: Finder recommends coordinator to vendor"): - recommendation = RecommendActor( + recommendation = RecommendActorActivity( actor=finder.as_id, as_object=coordinator.as_id, target=case.as_id, @@ -196,7 +208,7 @@ def demo_suggest_actor_accept( verify_object_stored(client, recommendation.as_id) with demo_step("Step 3: Vendor accepts recommendation"): - accept = AcceptActorRecommendation( + accept = AcceptActorRecommendationActivity( actor=vendor.as_id, as_object=recommendation.as_id, to=[finder.as_id], @@ -247,7 +259,7 @@ def demo_suggest_actor_reject( initial_count = len(initial_case.case_participants) if initial_case else 0 with demo_step("Step 2: Finder recommends coordinator to vendor"): - recommendation = RecommendActor( + recommendation = RecommendActorActivity( actor=finder.as_id, as_object=coordinator.as_id, target=case.as_id, @@ -263,7 +275,7 @@ def demo_suggest_actor_reject( verify_object_stored(client, recommendation.as_id) with demo_step("Step 3: Vendor rejects recommendation"): - reject = RejectActorRecommendation( + reject = RejectActorRecommendationActivity( actor=vendor.as_id, as_object=recommendation.as_id, to=[finder.as_id], diff --git a/vultron/demo/transfer_ownership_demo.py b/vultron/demo/transfer_ownership_demo.py index 813b731d..96301532 100644 --- a/vultron/demo/transfer_ownership_demo.py +++ b/vultron/demo/transfer_ownership_demo.py @@ -49,20 +49,29 @@ from typing import Optional, Sequence, Tuple # Vultron imports -from vultron.as_vocab.activities.case import ( - AcceptCaseOwnershipTransfer, - AddReportToCase, - CreateCase, - OfferCaseOwnershipTransfer, - RejectCaseOwnershipTransfer, +from vultron.wire.as2.vocab.activities.case import ( + AcceptCaseOwnershipTransferActivity, + AddReportToCaseActivity, + CreateCaseActivity, + OfferCaseOwnershipTransferActivity, + RejectCaseOwnershipTransferActivity, +) +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, +) +from vultron.wire.as2.vocab.activities.report import ( + RmSubmitReportActivity, + RmValidateReportActivity, +) +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.case_participant import ( + FinderReporterParticipant, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, ) -from vultron.as_vocab.activities.case_participant import AddParticipantToCase -from vultron.as_vocab.activities.report import RmSubmitReport, RmValidateReport -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.case_participant import FinderReporterParticipant -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport from vultron.demo.utils import ( DataLayerClient, check_server_availability, @@ -95,7 +104,7 @@ def _setup_initialized_case( content="A remote code execution vulnerability in the web framework.", name="Remote Code Execution Vulnerability", ) - report_offer = RmSubmitReport( + report_offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], @@ -104,7 +113,7 @@ def _setup_initialized_case( verify_object_stored(client, report.as_id) offer = get_offer_from_datalayer(client, vendor.as_id, report_offer.as_id) - validate_activity = RmValidateReport( + validate_activity = RmValidateReportActivity( actor=vendor.as_id, object=offer.as_id, content="Confirmed — remote code execution via unsanitized input.", @@ -116,14 +125,14 @@ def _setup_initialized_case( name="RCE Case — Web Framework", content="Tracking the RCE vulnerability in the web framework.", ) - create_case_activity = CreateCase( + create_case_activity = CreateCaseActivity( actor=vendor.as_id, as_object=case, ) post_to_inbox_and_wait(client, vendor.as_id, create_case_activity) verify_object_stored(client, case.as_id) - add_report_activity = AddReportToCase( + add_report_activity = AddReportToCaseActivity( actor=vendor.as_id, as_object=report.as_id, target=case.as_id, @@ -142,7 +151,7 @@ def _setup_initialized_case( post_to_inbox_and_wait(client, vendor.as_id, create_participant_activity) verify_object_stored(client, participant.as_id) - add_participant_activity = AddParticipantToCase( + add_participant_activity = AddParticipantToCaseActivity( actor=vendor.as_id, as_object=participant.as_id, target=case.as_id, @@ -167,8 +176,8 @@ def demo_transfer_ownership_accept( 1. Setup: initialize case (report submitted + validated, case created, finder participant added) 2. Vendor offers case ownership to coordinator - (OfferCaseOwnershipTransfer → coordinator inbox) - 3. Coordinator accepts (AcceptCaseOwnershipTransfer → vendor inbox) + (OfferCaseOwnershipTransferActivity → coordinator inbox) + 3. Coordinator accepts (AcceptCaseOwnershipTransferActivity → vendor inbox) 4. Verify case.attributed_to is updated to coordinator This follows the accept branch in @@ -187,7 +196,7 @@ def demo_transfer_ownership_accept( logger.info(f"Initial owner: {initial_case.attributed_to}") with demo_step("Step 2: Vendor offers case ownership to coordinator"): - offer = OfferCaseOwnershipTransfer( + offer = OfferCaseOwnershipTransferActivity( actor=vendor.as_id, as_object=case.as_id, to=[coordinator.as_id], @@ -199,7 +208,7 @@ def demo_transfer_ownership_accept( verify_object_stored(client, offer.as_id) with demo_step("Step 3: Coordinator accepts ownership transfer"): - accept = AcceptCaseOwnershipTransfer( + accept = AcceptCaseOwnershipTransferActivity( actor=coordinator.as_id, as_object=offer.as_id, to=[vendor.as_id], @@ -238,8 +247,8 @@ def demo_transfer_ownership_reject( 1. Setup: initialize case (report submitted + validated, case created, finder participant added) 2. Vendor offers case ownership to coordinator - (OfferCaseOwnershipTransfer → coordinator inbox) - 3. Coordinator rejects (RejectCaseOwnershipTransfer → vendor inbox) + (OfferCaseOwnershipTransferActivity → coordinator inbox) + 3. Coordinator rejects (RejectCaseOwnershipTransferActivity → vendor inbox) 4. Verify case.attributed_to remains with vendor This follows the reject branch in @@ -258,7 +267,7 @@ def demo_transfer_ownership_reject( logger.info(f"Initial owner: {original_owner}") with demo_step("Step 2: Vendor offers case ownership to coordinator"): - offer = OfferCaseOwnershipTransfer( + offer = OfferCaseOwnershipTransferActivity( actor=vendor.as_id, as_object=case.as_id, to=[coordinator.as_id], @@ -270,7 +279,7 @@ def demo_transfer_ownership_reject( verify_object_stored(client, offer.as_id) with demo_step("Step 3: Coordinator rejects ownership transfer"): - reject = RejectCaseOwnershipTransfer( + reject = RejectCaseOwnershipTransferActivity( actor=coordinator.as_id, as_object=offer.as_id, to=[vendor.as_id], diff --git a/vultron/demo/trigger_demo.py b/vultron/demo/trigger_demo.py index 07737f57..e7527a51 100644 --- a/vultron/demo/trigger_demo.py +++ b/vultron/demo/trigger_demo.py @@ -42,10 +42,12 @@ import sys from typing import Optional, Sequence, Tuple -from vultron.as_vocab.activities.report import RmSubmitReport -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.wire.as2.vocab.activities.report import RmSubmitReportActivity +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) from vultron.demo.utils import ( DataLayerClient, check_server_availability, @@ -83,7 +85,7 @@ def _submit_report( name=name, content=content, ) - offer = RmSubmitReport( + offer = RmSubmitReportActivity( actor=finder.as_id, as_object=report, to=[vendor.as_id], diff --git a/vultron/demo/utils.py b/vultron/demo/utils.py index deb38e5d..304b0b58 100644 --- a/vultron/demo/utils.py +++ b/vultron/demo/utils.py @@ -37,11 +37,11 @@ # Vultron imports from vultron.api.v2.data.actor_io import clear_all_actor_ios, init_actor_io from vultron.api.v2.data.utils import parse_id -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.activities.transitive import as_Offer -from vultron.as_vocab.base.objects.actors import as_Actor -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Offer +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase logger = logging.getLogger(__name__) diff --git a/vultron/enums.py b/vultron/enums.py deleted file mode 100644 index 4408d546..00000000 --- a/vultron/enums.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Enumeration definitions for the Vultron Protocol.""" - -from enum import auto, StrEnum - - -class MessageSemantics(StrEnum): - """Defines high-level semantics for certain activity patterns that may be relevant for behavior dispatching.""" - - CREATE_REPORT = auto() - SUBMIT_REPORT = auto() - VALIDATE_REPORT = auto() - INVALIDATE_REPORT = auto() - ACK_REPORT = auto() - CLOSE_REPORT = auto() - - CREATE_CASE = auto() - UPDATE_CASE = auto() - ENGAGE_CASE = auto() - DEFER_CASE = auto() - ADD_REPORT_TO_CASE = auto() - - SUGGEST_ACTOR_TO_CASE = auto() - ACCEPT_SUGGEST_ACTOR_TO_CASE = auto() - REJECT_SUGGEST_ACTOR_TO_CASE = auto() - OFFER_CASE_OWNERSHIP_TRANSFER = auto() - ACCEPT_CASE_OWNERSHIP_TRANSFER = auto() - REJECT_CASE_OWNERSHIP_TRANSFER = auto() - - INVITE_ACTOR_TO_CASE = auto() - ACCEPT_INVITE_ACTOR_TO_CASE = auto() - REJECT_INVITE_ACTOR_TO_CASE = auto() - - CREATE_EMBARGO_EVENT = auto() - ADD_EMBARGO_EVENT_TO_CASE = auto() - REMOVE_EMBARGO_EVENT_FROM_CASE = auto() - ANNOUNCE_EMBARGO_EVENT_TO_CASE = auto() - INVITE_TO_EMBARGO_ON_CASE = auto() - ACCEPT_INVITE_TO_EMBARGO_ON_CASE = auto() - REJECT_INVITE_TO_EMBARGO_ON_CASE = auto() - - CLOSE_CASE = auto() - - CREATE_CASE_PARTICIPANT = auto() - ADD_CASE_PARTICIPANT_TO_CASE = auto() - REMOVE_CASE_PARTICIPANT_FROM_CASE = auto() - - CREATE_NOTE = auto() - ADD_NOTE_TO_CASE = auto() - REMOVE_NOTE_FROM_CASE = auto() - - CREATE_CASE_STATUS = auto() - ADD_CASE_STATUS_TO_CASE = auto() - - CREATE_PARTICIPANT_STATUS = auto() - ADD_PARTICIPANT_STATUS_TO_PARTICIPANT = auto() - - # reserved for activities that don't fit any of the above semantics, but we want to be able to dispatch on them anyway - UNKNOWN = auto() - - -class OfferStatusEnum(StrEnum): - """Enumeration of Offer Statuses""" - - RECEIVED = "RECEIVED" - ACCEPTED = "ACCEPTED" - TENTATIVELY_REJECTED = "TENTATIVELY_REJECTED" - REJECTED = "REJECTED" - - -class VultronObjectType(StrEnum): - """Enumeration of Vultron Object Types for Activity Streams.""" - - VULNERABILITY_CASE = "VulnerabilityCase" - VULNERABILITY_REPORT = "VulnerabilityReport" - VULNERABILITY_RECORD = "VulnerabilityRecord" - CASE_REFERENCE = "CaseReference" - EMBARGO_POLICY = "EmbargoPolicy" - CASE_PARTICIPANT = "CaseParticipant" - CASE_STATUS = "CaseStatus" - PARTICIPANT_STATUS = "ParticipantStatus" - - -class as_ObjectType(StrEnum): - # generics - ACTIVITY = "Activity" - ACTOR = "Actor" - LINK = "Link" - # specific types - DOCUMENT = "Document" - IMAGE = "Image" - VIDEO = "Video" - AUDIO = "Audio" - PAGE = "Page" - ARTICLE = "Article" - NOTE = "Note" - EVENT = "Event" - PROFILE = "Profile" - TOMBSTONE = "Tombstone" - RELATIONSHIP = "Relationship" - PLACE = "Place" - - -class as_ActorType(StrEnum): - PERSON = "Person" - GROUP = "Group" - ORGANIZATION = "Organization" - APPLICATION = "Application" - SERVICE = "Service" - - -class as_IntransitiveActivityType(StrEnum): - TRAVEL = "Travel" - ARRIVE = "Arrive" - QUESTION = "Question" - - -class as_TransitiveActivityType(StrEnum): - LIKE = "Like" - IGNORE = "Ignore" - BLOCK = "Block" - OFFER = "Offer" - INVITE = "Invite" - FLAG = "Flag" - REMOVE = "Remove" - UNDO = "Undo" - CREATE = "Create" - DELETE = "Delete" - MOVE = "Move" - ADD = "Add" - JOIN = "Join" - UPDATE = "Update" - LISTEN = "Listen" - LEAVE = "Leave" - ANNOUNCE = "Announce" - FOLLOW = "Follow" - ACCEPT = "Accept" - TENTATIVE_ACCEPT = "TentativeAccept" - VIEW = "View" - DISLIKE = "Dislike" - REJECT = "Reject" - TENTATIVE_REJECT = "TentativeReject" - READ = "Read" - - -def merge_enums(name, enums: list[StrEnum]) -> StrEnum: - """Merge multiple StrEnum classes into a single StrEnum.""" - - values = {member.name: member.value for e in enums for member in e} - # sort the values by name - values = dict(sorted(values.items())) - return StrEnum(name, values) - - -as_ActivityType = merge_enums( - "as_ActivityType", [as_IntransitiveActivityType, as_TransitiveActivityType] -) -as_AllObjectTypes = merge_enums( - "as_AllObjectTypes", [as_ObjectType, as_ActorType, as_ActivityType] -) diff --git a/vultron/semantic_handler_map.py b/vultron/semantic_handler_map.py deleted file mode 100644 index fcf6dd19..00000000 --- a/vultron/semantic_handler_map.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Maps Message Semantics to their appropriate handlers -""" - -from vultron.enums import MessageSemantics -from vultron.types import BehaviorHandler - -# Cache for lazy initialization -_SEMANTICS_HANDLERS_CACHE: dict[MessageSemantics, BehaviorHandler] | None = ( - None -) - - -def get_semantics_handlers() -> dict[MessageSemantics, BehaviorHandler]: - """ - Returns the mapping of MessageSemantics to handler functions. - Uses lazy initialization to avoid circular imports. - """ - global _SEMANTICS_HANDLERS_CACHE - - if _SEMANTICS_HANDLERS_CACHE is not None: - return _SEMANTICS_HANDLERS_CACHE - - # Import handlers lazily to avoid circular import - from vultron.api.v2.backend import handlers as h - - _SEMANTICS_HANDLERS_CACHE = { - MessageSemantics.CREATE_REPORT: h.create_report, - MessageSemantics.SUBMIT_REPORT: h.submit_report, - MessageSemantics.VALIDATE_REPORT: h.validate_report, - MessageSemantics.INVALIDATE_REPORT: h.invalidate_report, - MessageSemantics.ACK_REPORT: h.ack_report, - MessageSemantics.CLOSE_REPORT: h.close_report, - MessageSemantics.CREATE_CASE: h.create_case, - MessageSemantics.UPDATE_CASE: h.update_case, - MessageSemantics.ENGAGE_CASE: h.engage_case, - MessageSemantics.DEFER_CASE: h.defer_case, - MessageSemantics.ADD_REPORT_TO_CASE: h.add_report_to_case, - MessageSemantics.SUGGEST_ACTOR_TO_CASE: h.suggest_actor_to_case, - MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE: h.accept_suggest_actor_to_case, - MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE: h.reject_suggest_actor_to_case, - MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER: h.offer_case_ownership_transfer, - MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER: h.accept_case_ownership_transfer, - MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER: h.reject_case_ownership_transfer, - MessageSemantics.INVITE_ACTOR_TO_CASE: h.invite_actor_to_case, - MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE: h.accept_invite_actor_to_case, - MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE: h.reject_invite_actor_to_case, - MessageSemantics.CREATE_EMBARGO_EVENT: h.create_embargo_event, - MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE: h.add_embargo_event_to_case, - MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE: h.remove_embargo_event_from_case, - MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE: h.announce_embargo_event_to_case, - MessageSemantics.INVITE_TO_EMBARGO_ON_CASE: h.invite_to_embargo_on_case, - MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE: h.accept_invite_to_embargo_on_case, - MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE: h.reject_invite_to_embargo_on_case, - MessageSemantics.CLOSE_CASE: h.close_case, - MessageSemantics.CREATE_CASE_PARTICIPANT: h.create_case_participant, - MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE: h.add_case_participant_to_case, - MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE: h.remove_case_participant_from_case, - MessageSemantics.CREATE_NOTE: h.create_note, - MessageSemantics.ADD_NOTE_TO_CASE: h.add_note_to_case, - MessageSemantics.REMOVE_NOTE_FROM_CASE: h.remove_note_from_case, - MessageSemantics.CREATE_CASE_STATUS: h.create_case_status, - MessageSemantics.ADD_CASE_STATUS_TO_CASE: h.add_case_status_to_case, - MessageSemantics.CREATE_PARTICIPANT_STATUS: h.create_participant_status, - MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT: h.add_participant_status_to_participant, - MessageSemantics.UNKNOWN: h.unknown, - } - - return _SEMANTICS_HANDLERS_CACHE diff --git a/vultron/semantic_map.py b/vultron/semantic_map.py deleted file mode 100644 index ba9e6422..00000000 --- a/vultron/semantic_map.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -This module defines the mapping between MessageSemantics and ActivityPatterns -intended for use in the behavior dispatcher to determine the semantics of incoming activities -based on their structure and content. -It provides a function to find the matching semantics for a given activity. -""" - -from vultron.activity_patterns import ActivityPattern -from vultron import activity_patterns as ap -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.enums import MessageSemantics - -# The order of the patterns in this dictionary matters for matching, -# as the find_matching_semantics function will return the first match it finds. -SEMANTICS_ACTIVITY_PATTERNS: dict[MessageSemantics, ActivityPattern] = { - MessageSemantics.CREATE_REPORT: ap.CreateReport, - MessageSemantics.SUBMIT_REPORT: ap.ReportSubmission, - MessageSemantics.ACK_REPORT: ap.AckReport, - MessageSemantics.VALIDATE_REPORT: ap.ValidateReport, - MessageSemantics.INVALIDATE_REPORT: ap.InvalidateReport, - MessageSemantics.CLOSE_REPORT: ap.CloseReport, - MessageSemantics.CREATE_CASE: ap.CreateCase, - MessageSemantics.UPDATE_CASE: ap.UpdateCase, - MessageSemantics.ENGAGE_CASE: ap.EngageCase, - MessageSemantics.DEFER_CASE: ap.DeferCase, - MessageSemantics.ADD_REPORT_TO_CASE: ap.AddReportToCase, - MessageSemantics.SUGGEST_ACTOR_TO_CASE: ap.SuggestActorToCase, - MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE: ap.AcceptSuggestActorToCase, - MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE: ap.RejectSuggestActorToCase, - MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER: ap.OfferCaseOwnershipTransfer, - MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER: ap.AcceptCaseOwnershipTransfer, - MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER: ap.RejectCaseOwnershipTransfer, - MessageSemantics.INVITE_ACTOR_TO_CASE: ap.InviteActorToCase, - MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE: ap.AcceptInviteActorToCase, - MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE: ap.RejectInviteActorToCase, - MessageSemantics.CREATE_EMBARGO_EVENT: ap.CreateEmbargoEvent, - MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE: ap.AddEmbargoEventToCase, - MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE: ap.RemoveEmbargoEventFromCase, - MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE: ap.AnnounceEmbargoEventToCase, - MessageSemantics.INVITE_TO_EMBARGO_ON_CASE: ap.InviteToEmbargoOnCase, - MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE: ap.AcceptInviteToEmbargoOnCase, - MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE: ap.RejectInviteToEmbargoOnCase, - MessageSemantics.CLOSE_CASE: ap.CloseCase, - MessageSemantics.CREATE_CASE_PARTICIPANT: ap.CreateCaseParticipant, - MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE: ap.AddCaseParticipantToCase, - MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE: ap.RemoveCaseParticipantFromCase, - MessageSemantics.CREATE_NOTE: ap.CreateNote, - MessageSemantics.ADD_NOTE_TO_CASE: ap.AddNoteToCase, - MessageSemantics.REMOVE_NOTE_FROM_CASE: ap.RemoveNoteFromCase, - MessageSemantics.CREATE_CASE_STATUS: ap.CreateCaseStatus, - MessageSemantics.ADD_CASE_STATUS_TO_CASE: ap.AddCaseStatusToCase, - MessageSemantics.CREATE_PARTICIPANT_STATUS: ap.CreateParticipantStatus, - MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT: ap.AddParticipantStatusToParticipant, -} - -# TODO test that there are no overlapping patterns in SEMANTICS_ACTIVITY_PATTERNS -# that could cause ambiguity in matching - - -def find_matching_semantics( - activity: as_Activity, -) -> MessageSemantics: - """ - Finds the matching semantics for a given activity, if any. - - Note that this returns the first matching semantics it finds, - so the order of the patterns in SEMANTICS_ACTIVITY_PATTERNS may affect - the results if there are overlapping patterns. - - Args: - activity: The activity to find semantics for. - Returns: - The matching MessageSemantics StrEnum value, - or MessageSemantics.UNKNOWN if no match is found. - """ - # Implementation note: - # Looping through all semantics in order is not optimized for performance, - # but it is good enough to start with as long as - # - the pattern count is relatively small and - # - the patterns are not too complex. - for semantics, pattern in SEMANTICS_ACTIVITY_PATTERNS.items(): - if pattern.match(activity): - return semantics - return MessageSemantics.UNKNOWN - - -if __name__ == "__main__": - from vultron.as_vocab.examples import vocab_examples - - examples = [ - vocab_examples.create_report(), - vocab_examples.submit_report(), - vocab_examples.validate_report(verbose=True), - vocab_examples.invalidate_report(verbose=True), - vocab_examples.close_report(verbose=True), - ] - - for activity in examples: - try: - semantics = find_matching_semantics(activity) - except Exception as e: - print(f"Error finding semantics for activity: {e}") - print( - f"Activity: {activity.model_dump_json(indent=2,exclude_none=True)}" - ) - continue - print(f"## {semantics}") - print( - f"Activity: {activity.model_dump_json(indent=2,exclude_none=True)}" - ) diff --git a/vultron/types.py b/vultron/types.py index 69035d76..80e699c2 100644 --- a/vultron/types.py +++ b/vultron/types.py @@ -1,35 +1,35 @@ -""" -Shared type definitions for Vultron. +from __future__ import annotations -This module contains common types used across the codebase to avoid circular imports. -""" - -from typing import Protocol +from typing import Protocol, TYPE_CHECKING from pydantic import BaseModel -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.enums import MessageSemantics +from vultron.core.models.events import MessageSemantics, VultronEvent +if TYPE_CHECKING: + from vultron.core.ports.datalayer import DataLayer -class DispatchActivity(BaseModel): - """ - Data model to represent a dispatchable activity with its associated message semantics as a header. + +class DispatchEvent(BaseModel): + """Data model representing a domain event ready for dispatch. + + Wraps a ``VultronEvent`` payload with routing metadata (semantic type and + activity ID). This is a pure domain object — it carries no wire-layer + (AS2) fields. """ semantic_type: MessageSemantics activity_id: str - payload: as_Activity - # We are deliberately not including case_id or report_id here because - # where they are located in the payload can vary depending on message semantics. - # Therefore it is better to leave it to downstream semantic-specific handlers to - # extract those values for logging or other purposes rather than having to build - # a parallel extraction logic here in the dispatcher that may not be universally applicable. + payload: VultronEvent + + +# Backward-compat alias — will be removed once P75-2c flattens the handler layer. +DispatchActivity = DispatchEvent class BehaviorHandler(Protocol): - """ - Protocol for behavior handler functions. - """ + """Protocol for behavior handler functions.""" - def __call__(self, dispatchable: DispatchActivity) -> None: ... + def __call__( + self, dispatchable: DispatchEvent, dl: "DataLayer" + ) -> None: ... diff --git a/vultron/wire/__init__.py b/vultron/wire/__init__.py new file mode 100644 index 00000000..480b750a --- /dev/null +++ b/vultron/wire/__init__.py @@ -0,0 +1,6 @@ +"""Wire layer for the Vultron Protocol. + +Handles parsing and semantic extraction of inbound ActivityStreams activities. +This layer sits between the driving adapters (HTTP, CLI, MCP) and the core +domain layer. +""" diff --git a/vultron/wire/as2/__init__.py b/vultron/wire/as2/__init__.py new file mode 100644 index 00000000..cf7d1084 --- /dev/null +++ b/vultron/wire/as2/__init__.py @@ -0,0 +1,4 @@ +"""AS2 (ActivityStreams 2.0) wire layer for the Vultron Protocol. + +Provides parsing and semantic extraction for inbound AS2 activities. +""" diff --git a/vultron/wire/as2/enums.py b/vultron/wire/as2/enums.py new file mode 100644 index 00000000..b6f45034 --- /dev/null +++ b/vultron/wire/as2/enums.py @@ -0,0 +1,85 @@ +"""AS2 structural enum definitions for the Vultron wire layer. + +These enums map directly to ActivityStreams 2.0 vocabulary types. +""" + +from enum import StrEnum + + +class as_ObjectType(StrEnum): + # generics + ACTIVITY = "Activity" + ACTOR = "Actor" + LINK = "Link" + # specific types + DOCUMENT = "Document" + IMAGE = "Image" + VIDEO = "Video" + AUDIO = "Audio" + PAGE = "Page" + ARTICLE = "Article" + NOTE = "Note" + EVENT = "Event" + PROFILE = "Profile" + TOMBSTONE = "Tombstone" + RELATIONSHIP = "Relationship" + PLACE = "Place" + + +class as_ActorType(StrEnum): + PERSON = "Person" + GROUP = "Group" + ORGANIZATION = "Organization" + APPLICATION = "Application" + SERVICE = "Service" + + +class as_IntransitiveActivityType(StrEnum): + TRAVEL = "Travel" + ARRIVE = "Arrive" + QUESTION = "Question" + + +class as_TransitiveActivityType(StrEnum): + LIKE = "Like" + IGNORE = "Ignore" + BLOCK = "Block" + OFFER = "Offer" + INVITE = "Invite" + FLAG = "Flag" + REMOVE = "Remove" + UNDO = "Undo" + CREATE = "Create" + DELETE = "Delete" + MOVE = "Move" + ADD = "Add" + JOIN = "Join" + UPDATE = "Update" + LISTEN = "Listen" + LEAVE = "Leave" + ANNOUNCE = "Announce" + FOLLOW = "Follow" + ACCEPT = "Accept" + TENTATIVE_ACCEPT = "TentativeAccept" + VIEW = "View" + DISLIKE = "Dislike" + REJECT = "Reject" + TENTATIVE_REJECT = "TentativeReject" + READ = "Read" + + +def merge_enums(name, enums: list[StrEnum]) -> StrEnum: + """Merge multiple StrEnum classes into a single StrEnum.""" + + values = {member.name: member.value for e in enums for member in e} + # sort the values by name + values = dict(sorted(values.items())) + return StrEnum(name, values) + + +as_ActivityType = merge_enums( + "as_ActivityType", [as_IntransitiveActivityType, as_TransitiveActivityType] +) +as_AllObjectTypes = merge_enums( + "as_AllObjectTypes", [as_ObjectType, as_ActorType, as_ActivityType] +) diff --git a/vultron/wire/as2/errors.py b/vultron/wire/as2/errors.py new file mode 100644 index 00000000..dd181a9a --- /dev/null +++ b/vultron/wire/as2/errors.py @@ -0,0 +1,19 @@ +"""AS2 wire layer errors for the Vultron Protocol.""" + +from vultron.wire.errors import VultronWireError + + +class VultronParseError(VultronWireError): + """Raised when AS2 activity parsing fails.""" + + +class VultronParseMissingTypeError(VultronParseError): + """Raised when the 'type' field is missing from an AS2 activity body.""" + + +class VultronParseUnknownTypeError(VultronParseError): + """Raised when the activity type is not found in the AS2 vocabulary.""" + + +class VultronParseValidationError(VultronParseError): + """Raised when an AS2 activity fails Pydantic schema validation.""" diff --git a/vultron/wire/as2/extractor.py b/vultron/wire/as2/extractor.py new file mode 100644 index 00000000..04fea458 --- /dev/null +++ b/vultron/wire/as2/extractor.py @@ -0,0 +1,560 @@ +"""AS2 wire layer semantic extractor for the Vultron Protocol. + +Maps AS2 activity structures to domain MessageSemantics. This is the sole +location where AS2 vocabulary is translated to domain concepts (ARCH-03-001). +This is stage 3 of the inbound pipeline: typed AS2 activity → MessageSemantics. + +Consolidates ActivityPattern definitions (formerly in vultron/activity_patterns.py) +and the SEMANTICS_ACTIVITY_PATTERNS mapping + find_matching_semantics function +(formerly in vultron/semantic_map.py) into a single extractor module. +""" + +from typing import Optional, Union + +from pydantic import BaseModel + +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.core.models.events import MessageSemantics +from vultron.core.models.enums import VultronObjectType as VOtype +from vultron.wire.as2.enums import ( + as_IntransitiveActivityType as IAtype, + as_ObjectType as AOtype, + as_TransitiveActivityType as TAtype, +) + + +class ActivityPattern(BaseModel): + """Represents a pattern to match against an AS2 activity for semantic dispatch. + + Supports nested patterns for activities whose object is itself an activity. + """ + + description: Optional[str] = None + activity_: TAtype | IAtype + + to_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None + object_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None + target_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None + context_: Optional[Union[AOtype, VOtype, "ActivityPattern"]] = None + in_reply_to_: Optional["ActivityPattern"] = None + + def match(self, activity: as_Activity) -> bool: + """Return True if the given activity matches this pattern.""" + if self.activity_ != activity.as_type: + return False + + def _match_field(pattern_field, activity_field) -> bool: + if pattern_field is None: + return True + # URI/ID string reference: can't type-check, conservatively allow + if isinstance(activity_field, str): + return True + if isinstance(pattern_field, ActivityPattern): + return pattern_field.match(activity_field) + return pattern_field == getattr(activity_field, "as_type", None) + + if not _match_field( + self.object_, getattr(activity, "as_object", None) + ): + return False + if not _match_field(self.target_, getattr(activity, "target", None)): + return False + if not _match_field(self.context_, getattr(activity, "context", None)): + return False + if not _match_field(self.to_, getattr(activity, "to", None)): + return False + if not _match_field( + self.in_reply_to_, getattr(activity, "in_reply_to", None) + ): + return False + + return True + + +# --------------------------------------------------------------------------- +# Pattern instances (formerly in vultron/activity_patterns.py) +# --------------------------------------------------------------------------- + +CreateEmbargoEvent = ActivityPattern( + description=( + "Create an embargo event. This is the initial step in the embargo " + "management process, where a coordinator creates an embargo event to " + "manage the embargo on a vulnerability case." + ), + activity_=TAtype.CREATE, + object_=AOtype.EVENT, + context_=VOtype.VULNERABILITY_CASE, +) +AddEmbargoEventToCase = ActivityPattern( + description=( + "Add an embargo event to a vulnerability case. This is typically " + "observed as an ADD activity where the object is an EVENT and the " + "target is a VULNERABILITY_CASE." + ), + activity_=TAtype.ADD, + object_=AOtype.EVENT, + target_=VOtype.VULNERABILITY_CASE, +) +RemoveEmbargoEventFromCase = ActivityPattern( + description=( + "Remove an embargo event from a vulnerability case. This is typically " + "observed as a REMOVE activity where the object is an EVENT. The " + "origin field of the activity contains the VulnerabilityCase from " + "which the embargo is removed." + ), + activity_=TAtype.REMOVE, + object_=AOtype.EVENT, +) +AnnounceEmbargoEventToCase = ActivityPattern( + description=( + "Announce an embargo event to a vulnerability case. This is typically " + "observed as an ANNOUNCE activity where the object is an EVENT and the " + "context is a VULNERABILITY_CASE." + ), + activity_=TAtype.ANNOUNCE, + object_=AOtype.EVENT, + context_=VOtype.VULNERABILITY_CASE, +) +InviteToEmbargoOnCase = ActivityPattern( + description=( + "Propose an embargo on a vulnerability case. " + "This is observed as an INVITE activity where the object is an " + "EmbargoEvent and the context is the VulnerabilityCase. " + "Corresponds to EmProposeEmbargoActivity." + ), + activity_=TAtype.INVITE, + object_=AOtype.EVENT, + context_=VOtype.VULNERABILITY_CASE, +) +AcceptInviteToEmbargoOnCase = ActivityPattern( + description="Accept an invitation to an embargo on a vulnerability case.", + activity_=TAtype.ACCEPT, + object_=InviteToEmbargoOnCase, +) +RejectInviteToEmbargoOnCase = ActivityPattern( + description="Reject an invitation to an embargo on a vulnerability case.", + activity_=TAtype.REJECT, + object_=InviteToEmbargoOnCase, +) +CreateReport = ActivityPattern( + description=( + "Create a vulnerability report. This is the initial step in the " + "vulnerability disclosure process, where a finder creates a report to " + "disclose a vulnerability. It may not always be observed directly, as " + "it could be implicit in the OFFER of the report." + ), + activity_=TAtype.CREATE, + object_=VOtype.VULNERABILITY_REPORT, +) +ReportSubmission = ActivityPattern( + description=( + "Submit a vulnerability report for validation. This is typically " + "observed as an OFFER of a VULNERABILITY_REPORT, which represents the " + "submission of the report to a coordinator or vendor for validation." + ), + activity_=TAtype.OFFER, + object_=VOtype.VULNERABILITY_REPORT, +) +AckReport = ActivityPattern(activity_=TAtype.READ, object_=ReportSubmission) +ValidateReport = ActivityPattern( + activity_=TAtype.ACCEPT, object_=ReportSubmission +) +InvalidateReport = ActivityPattern( + activity_=TAtype.TENTATIVE_REJECT, object_=ReportSubmission +) +CloseReport = ActivityPattern( + activity_=TAtype.REJECT, object_=ReportSubmission +) +CreateCaseActivity = ActivityPattern( + activity_=TAtype.CREATE, object_=VOtype.VULNERABILITY_CASE +) +UpdateCaseActivity = ActivityPattern( + activity_=TAtype.UPDATE, object_=VOtype.VULNERABILITY_CASE +) +EngageCase = ActivityPattern( + description=( + "Actor engages (joins) a VulnerabilityCase, transitioning their RM " + "state to ACCEPTED." + ), + activity_=TAtype.JOIN, + object_=VOtype.VULNERABILITY_CASE, +) +DeferCase = ActivityPattern( + description=( + "Actor defers (ignores) a VulnerabilityCase, transitioning their RM " + "state to DEFERRED." + ), + activity_=TAtype.IGNORE, + object_=VOtype.VULNERABILITY_CASE, +) +AddReportToCaseActivity = ActivityPattern( + activity_=TAtype.ADD, + object_=VOtype.VULNERABILITY_REPORT, + target_=VOtype.VULNERABILITY_CASE, +) +SuggestActorToCase = ActivityPattern( + activity_=TAtype.OFFER, + object_=AOtype.ACTOR, + target_=VOtype.VULNERABILITY_CASE, +) +AcceptSuggestActorToCase = ActivityPattern( + activity_=TAtype.ACCEPT, object_=SuggestActorToCase +) +RejectSuggestActorToCase = ActivityPattern( + activity_=TAtype.REJECT, object_=SuggestActorToCase +) +OfferCaseOwnershipTransferActivity = ActivityPattern( + activity_=TAtype.OFFER, object_=VOtype.VULNERABILITY_CASE +) +AcceptCaseOwnershipTransferActivity = ActivityPattern( + activity_=TAtype.ACCEPT, object_=OfferCaseOwnershipTransferActivity +) +RejectCaseOwnershipTransferActivity = ActivityPattern( + activity_=TAtype.REJECT, object_=OfferCaseOwnershipTransferActivity +) +InviteActorToCase = ActivityPattern( + activity_=TAtype.INVITE, + target_=VOtype.VULNERABILITY_CASE, +) +AcceptInviteActorToCase = ActivityPattern( + activity_=TAtype.ACCEPT, + object_=InviteActorToCase, +) +RejectInviteActorToCase = ActivityPattern( + activity_=TAtype.REJECT, + object_=InviteActorToCase, +) +CloseCase = ActivityPattern( + activity_=TAtype.LEAVE, object_=VOtype.VULNERABILITY_CASE +) +CreateNote = ActivityPattern( + activity_=TAtype.CREATE, + object_=AOtype.NOTE, +) +AddNoteToCaseActivity = ActivityPattern( + activity_=TAtype.ADD, + object_=AOtype.NOTE, + target_=VOtype.VULNERABILITY_CASE, +) +RemoveNoteFromCase = ActivityPattern( + activity_=TAtype.REMOVE, + object_=AOtype.NOTE, + target_=VOtype.VULNERABILITY_CASE, +) +CreateCaseParticipant = ActivityPattern( + activity_=TAtype.CREATE, + object_=VOtype.CASE_PARTICIPANT, + context_=VOtype.VULNERABILITY_CASE, +) +AddCaseParticipantToCase = ActivityPattern( + activity_=TAtype.ADD, + object_=VOtype.CASE_PARTICIPANT, + target_=VOtype.VULNERABILITY_CASE, +) +RemoveCaseParticipantFromCase = ActivityPattern( + activity_=TAtype.REMOVE, + object_=VOtype.CASE_PARTICIPANT, + target_=VOtype.VULNERABILITY_CASE, +) +CreateCaseStatusActivity = ActivityPattern( + activity_=TAtype.CREATE, + object_=VOtype.CASE_STATUS, + context_=VOtype.VULNERABILITY_CASE, +) +AddCaseStatusToCase = ActivityPattern( + activity_=TAtype.ADD, + object_=VOtype.CASE_STATUS, + target_=VOtype.VULNERABILITY_CASE, +) +CreateParticipantStatus = ActivityPattern( + activity_=TAtype.CREATE, + object_=VOtype.PARTICIPANT_STATUS, +) +AddParticipantStatusToParticipant = ActivityPattern( + activity_=TAtype.ADD, + object_=VOtype.PARTICIPANT_STATUS, + target_=VOtype.CASE_PARTICIPANT, +) + + +# --------------------------------------------------------------------------- +# Semantics → pattern mapping (formerly in vultron/semantic_map.py) +# The order of entries matters: find_matching_semantics returns the first match. +# --------------------------------------------------------------------------- + +SEMANTICS_ACTIVITY_PATTERNS: dict[MessageSemantics, ActivityPattern] = { + MessageSemantics.CREATE_REPORT: CreateReport, + MessageSemantics.SUBMIT_REPORT: ReportSubmission, + MessageSemantics.ACK_REPORT: AckReport, + MessageSemantics.VALIDATE_REPORT: ValidateReport, + MessageSemantics.INVALIDATE_REPORT: InvalidateReport, + MessageSemantics.CLOSE_REPORT: CloseReport, + MessageSemantics.CREATE_CASE: CreateCaseActivity, + MessageSemantics.UPDATE_CASE: UpdateCaseActivity, + MessageSemantics.ENGAGE_CASE: EngageCase, + MessageSemantics.DEFER_CASE: DeferCase, + MessageSemantics.ADD_REPORT_TO_CASE: AddReportToCaseActivity, + MessageSemantics.SUGGEST_ACTOR_TO_CASE: SuggestActorToCase, + MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE: AcceptSuggestActorToCase, + MessageSemantics.REJECT_SUGGEST_ACTOR_TO_CASE: RejectSuggestActorToCase, + MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER: OfferCaseOwnershipTransferActivity, + MessageSemantics.ACCEPT_CASE_OWNERSHIP_TRANSFER: AcceptCaseOwnershipTransferActivity, + MessageSemantics.REJECT_CASE_OWNERSHIP_TRANSFER: RejectCaseOwnershipTransferActivity, + MessageSemantics.INVITE_ACTOR_TO_CASE: InviteActorToCase, + MessageSemantics.ACCEPT_INVITE_ACTOR_TO_CASE: AcceptInviteActorToCase, + MessageSemantics.REJECT_INVITE_ACTOR_TO_CASE: RejectInviteActorToCase, + MessageSemantics.CREATE_EMBARGO_EVENT: CreateEmbargoEvent, + MessageSemantics.ADD_EMBARGO_EVENT_TO_CASE: AddEmbargoEventToCase, + MessageSemantics.REMOVE_EMBARGO_EVENT_FROM_CASE: RemoveEmbargoEventFromCase, + MessageSemantics.ANNOUNCE_EMBARGO_EVENT_TO_CASE: AnnounceEmbargoEventToCase, + MessageSemantics.INVITE_TO_EMBARGO_ON_CASE: InviteToEmbargoOnCase, + MessageSemantics.ACCEPT_INVITE_TO_EMBARGO_ON_CASE: AcceptInviteToEmbargoOnCase, + MessageSemantics.REJECT_INVITE_TO_EMBARGO_ON_CASE: RejectInviteToEmbargoOnCase, + MessageSemantics.CLOSE_CASE: CloseCase, + MessageSemantics.CREATE_CASE_PARTICIPANT: CreateCaseParticipant, + MessageSemantics.ADD_CASE_PARTICIPANT_TO_CASE: AddCaseParticipantToCase, + MessageSemantics.REMOVE_CASE_PARTICIPANT_FROM_CASE: RemoveCaseParticipantFromCase, + MessageSemantics.CREATE_NOTE: CreateNote, + MessageSemantics.ADD_NOTE_TO_CASE: AddNoteToCaseActivity, + MessageSemantics.REMOVE_NOTE_FROM_CASE: RemoveNoteFromCase, + MessageSemantics.CREATE_CASE_STATUS: CreateCaseStatusActivity, + MessageSemantics.ADD_CASE_STATUS_TO_CASE: AddCaseStatusToCase, + MessageSemantics.CREATE_PARTICIPANT_STATUS: CreateParticipantStatus, + MessageSemantics.ADD_PARTICIPANT_STATUS_TO_PARTICIPANT: AddParticipantStatusToParticipant, +} + + +def extract_intent( + activity: as_Activity, +) -> "VultronEvent": + """Extract semantic intent and domain fields from an AS2 activity. + + Returns a fully-populated per-semantic VultronEvent subclass with all + relevant IDs and types extracted from the AS2 object graph. + This is the sole point where AS2 wire types are translated to domain concepts. + + Args: + activity: The AS2 activity to classify and extract from. + + Returns: + A concrete VultronEvent subclass discriminated by MessageSemantics. + """ + from vultron.core.models.events import EVENT_CLASS_MAP, VultronEvent + + semantics = find_matching_semantics(activity) + + def _get_id(field) -> str | None: + if field is None: + return None + if isinstance(field, str): + return field + return getattr(field, "as_id", str(field)) or None + + def _get_type(field) -> str | None: + if field is None or isinstance(field, str): + return None + t = getattr(field, "as_type", None) + return str(t) if t is not None else None + + actor_id = _get_id(getattr(activity, "actor", None)) or "" + obj = getattr(activity, "as_object", None) + target = getattr(activity, "target", None) + context = getattr(activity, "context", None) + origin = getattr(activity, "origin", None) + + # Nested fields from activity.as_object (for Accept/Reject wrapping another activity) + inner_obj = None + inner_target = None + inner_context = None + if obj is not None and not isinstance(obj, str): + inner_obj = getattr(obj, "as_object", None) + inner_target = getattr(obj, "target", None) + inner_context = getattr(obj, "context", None) + + event_class: type[VultronEvent] = EVENT_CLASS_MAP.get( + semantics, EVENT_CLASS_MAP[MessageSemantics.UNKNOWN] + ) + + def _build_domain_kwargs() -> dict: + from vultron.core.models.vultron_types import ( + VultronActivity, + VultronCase, + VultronEmbargoEvent, + VultronNote, + VultronParticipant, + VultronParticipantStatus, + VultronCaseStatus, + VultronReport, + ) + + # Use as_type string comparison because the wire parser returns + # as_Object (base class) for nested objects; isinstance checks against + # Vultron subtypes (EmbargoEvent, CaseParticipant, etc.) would always + # fail. Match on as_type string, consistent with ActivityPattern._match_field. + _obj_type = str(getattr(obj, "as_type", "")) if obj is not None else "" + + kw: dict = {} + activity_type = ( + str(activity.as_type) if activity.as_type else "Activity" + ) + + _ACTIVITY_SEMANTICS = { + MessageSemantics.CREATE_REPORT, + MessageSemantics.SUBMIT_REPORT, + MessageSemantics.VALIDATE_REPORT, + MessageSemantics.INVALIDATE_REPORT, + MessageSemantics.ACK_REPORT, + MessageSemantics.CLOSE_REPORT, + MessageSemantics.SUGGEST_ACTOR_TO_CASE, + MessageSemantics.ACCEPT_SUGGEST_ACTOR_TO_CASE, + MessageSemantics.OFFER_CASE_OWNERSHIP_TRANSFER, + MessageSemantics.INVITE_ACTOR_TO_CASE, + MessageSemantics.INVITE_TO_EMBARGO_ON_CASE, + MessageSemantics.CREATE_CASE, + } + if semantics in _ACTIVITY_SEMANTICS: + kw["activity"] = VultronActivity( + as_id=activity.as_id, + as_type=activity_type, + actor=actor_id, + as_object=_get_id(obj), + target=_get_id(target), + origin=_get_id(origin), + context=_get_id(context), + in_reply_to=_get_id(getattr(activity, "in_reply_to", None)), + ) + + if _obj_type == str(VOtype.VULNERABILITY_REPORT): + kw["report"] = VultronReport( + as_id=obj.as_id, + as_type=str(obj.as_type), + name=obj.name, + summary=getattr(obj, "summary", None), + content=obj.content, + url=_get_id(getattr(obj, "url", None)), + media_type=getattr(obj, "media_type", None), + attributed_to=_get_id(getattr(obj, "attributed_to", None)), + context=_get_id(getattr(obj, "context", None)), + published=getattr(obj, "published", None), + updated=getattr(obj, "updated", None), + ) + elif _obj_type == str(VOtype.VULNERABILITY_CASE): + kw["case"] = VultronCase( + as_id=obj.as_id, + as_type=str(obj.as_type), + name=getattr(obj, "name", None), + summary=getattr(obj, "summary", None), + content=getattr(obj, "content", None), + url=_get_id(getattr(obj, "url", None)), + attributed_to=_get_id(getattr(obj, "attributed_to", None)), + published=getattr(obj, "published", None), + updated=getattr(obj, "updated", None), + ) + elif _obj_type == str(AOtype.EVENT): + kw["embargo"] = VultronEmbargoEvent( + as_id=obj.as_id, + as_type=str(obj.as_type), + name=getattr(obj, "name", None), + start_time=getattr(obj, "start_time", None), + end_time=getattr(obj, "end_time", None), + published=getattr(obj, "published", None), + updated=getattr(obj, "updated", None), + context=_get_id(getattr(obj, "context", None)), + ) + elif _obj_type == str(VOtype.CASE_PARTICIPANT): + kw["participant"] = VultronParticipant( + as_id=obj.as_id, + as_type=str(obj.as_type), + name=getattr(obj, "name", None), + attributed_to=_get_id(getattr(obj, "attributed_to", None)), + context=_get_id(getattr(obj, "context", None)), + case_roles=list(getattr(obj, "case_roles", []) or []), + participant_case_name=getattr( + obj, "participant_case_name", None + ), + ) + elif _obj_type == str(AOtype.NOTE): + kw["note"] = VultronNote( + as_id=obj.as_id, + name=getattr(obj, "name", None), + summary=getattr(obj, "summary", None), + content=getattr(obj, "content", None), + url=_get_id(getattr(obj, "url", None)), + attributed_to=_get_id(getattr(obj, "attributed_to", None)), + context=_get_id(getattr(obj, "context", None)), + ) + elif _obj_type == str(VOtype.CASE_STATUS): + kw["status"] = VultronCaseStatus( + as_id=obj.as_id, + name=getattr(obj, "name", None), + context=_get_id(getattr(obj, "context", None)), + attributed_to=_get_id(getattr(obj, "attributed_to", None)), + em_state=getattr(obj, "em_state", None) + or VultronCaseStatus.model_fields["em_state"].default, + pxa_state=getattr(obj, "pxa_state", None) + or VultronCaseStatus.model_fields["pxa_state"].default, + ) + elif _obj_type == str(VOtype.PARTICIPANT_STATUS): + ctx = _get_id(getattr(obj, "context", None)) or "" + wire_case_status = getattr(obj, "case_status", None) + kw["status"] = VultronParticipantStatus( + as_id=obj.as_id, + name=getattr(obj, "name", None), + context=ctx, + attributed_to=_get_id(getattr(obj, "attributed_to", None)), + rm_state=getattr(obj, "rm_state", None) + or VultronParticipantStatus.model_fields["rm_state"].default, + vfd_state=getattr(obj, "vfd_state", None) + or VultronParticipantStatus.model_fields["vfd_state"].default, + case_status=( + _get_id(wire_case_status) + if wire_case_status is not None + else None + ), + ) + return kw + + extra_kwargs = _build_domain_kwargs() + return event_class( + semantic_type=semantics, + activity_id=activity.as_id, + activity_type=str(activity.as_type) if activity.as_type else None, + actor_id=actor_id, + object_id=_get_id(obj), + object_type=_get_type(obj), + target_id=_get_id(target), + target_type=_get_type(target), + context_id=_get_id(context), + context_type=_get_type(context), + origin_id=_get_id(origin), + origin_type=_get_type(origin), + inner_object_id=_get_id(inner_obj), + inner_object_type=_get_type(inner_obj), + inner_target_id=_get_id(inner_target), + inner_target_type=_get_type(inner_target), + inner_context_id=_get_id(inner_context), + inner_context_type=_get_type(inner_context), + **extra_kwargs, + ) + + +def find_matching_semantics(activity: as_Activity) -> MessageSemantics: + """Find the MessageSemantics for the given AS2 activity. + + Iterates SEMANTICS_ACTIVITY_PATTERNS in order and returns the first match. + Returns MessageSemantics.UNKNOWN if no pattern matches. + + Note: + Pattern ordering matters when patterns overlap. More specific patterns + must appear before more general ones. + + Args: + activity: The AS2 activity to classify. + + Returns: + The matching MessageSemantics value, or MessageSemantics.UNKNOWN. + """ + for semantics, pattern in SEMANTICS_ACTIVITY_PATTERNS.items(): + if pattern.match(activity): + return semantics + return MessageSemantics.UNKNOWN diff --git a/vultron/wire/as2/parser.py b/vultron/wire/as2/parser.py new file mode 100644 index 00000000..760c83bc --- /dev/null +++ b/vultron/wire/as2/parser.py @@ -0,0 +1,57 @@ +"""AS2 wire layer parser for the Vultron Protocol. + +Converts raw request bodies (dicts) into typed as_Activity objects. +This is stage 2 of the inbound pipeline: raw dict → typed AS2 activity. +Raises domain errors; driving adapters are responsible for mapping these +to transport-level error responses (e.g., HTTP status codes). +""" + +import logging + +from vultron.wire.as2.vocab import VOCABULARY +from vultron.wire.as2.vocab.type_helpers import AsActivityType +from vultron.wire.as2.errors import ( + VultronParseMissingTypeError, + VultronParseUnknownTypeError, + VultronParseValidationError, +) + +logger = logging.getLogger(__name__) + + +def parse_activity(body: dict) -> AsActivityType: + """Parse a raw dict into a typed as_Activity object. + + This is stage 2 of the inbound pipeline. It validates the `type` field, + looks up the corresponding class in the AS2 vocabulary, and runs Pydantic + validation. + + Args: + body: The raw request body as a dictionary. + + Returns: + A typed as_Activity subclass instance. + + Raises: + VultronParseMissingTypeError: If the `type` field is absent. + VultronParseUnknownTypeError: If the `type` value is not in the vocabulary. + VultronParseValidationError: If Pydantic validation fails. + """ + logger.info("Parsing activity from body (type=%r)", body.get("type")) + + as_type = body.get("type") + if as_type is None: + raise VultronParseMissingTypeError( + "Missing 'type' field in activity body." + ) + + cls = VOCABULARY.activities.get(as_type) + if cls is None: + raise VultronParseUnknownTypeError( + f"Unrecognized activity type: {as_type!r}." + ) + + try: + return cls.model_validate(body) + except Exception as exc: + raise VultronParseValidationError(str(exc)) from exc diff --git a/vultron/wire/as2/serializer.py b/vultron/wire/as2/serializer.py new file mode 100644 index 00000000..a7086295 --- /dev/null +++ b/vultron/wire/as2/serializer.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Outbound serializer: converts core domain types to AS2 wire format. + +This module is the single seam between the core domain layer and the AS2 +wire layer for outbound (locally-originated) activities. Adapter-layer +code (handlers, trigger services) SHOULD use these helpers rather than +importing core domain types alongside wire types in the same module. + +Core BT nodes in ``vultron/core/behaviors/`` MUST NOT import from this +module — they work exclusively with domain types from +``vultron/core/models/vultron_types.py``. + +Per ``notes/domain-model-separation.md`` (Outbound Event Design Questions) +and the P65-6b task in ``plan/IMPLEMENTATION_PLAN.md``. +""" + +from vultron.core.models.vultron_types import ( + VultronCase, + VultronCaseActor, + VultronCreateCaseActivity, + VultronParticipant, + VultronParticipantStatus, + VultronReport, +) +from vultron.wire.as2.vocab.activities.case import ( + CreateCaseActivity as as_CreateCase, +) +from vultron.wire.as2.vocab.objects.case_actor import CaseActor +from vultron.wire.as2.vocab.objects.case_participant import VendorParticipant +from vultron.wire.as2.vocab.objects.case_status import ( + ParticipantStatus, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) + + +def domain_case_to_wire(domain: VultronCase) -> VulnerabilityCase: + """Convert a ``VultronCase`` domain object to a ``VulnerabilityCase`` wire object.""" + return VulnerabilityCase( + as_id=domain.as_id, + name=domain.name, + attributed_to=domain.attributed_to, + context=domain.context, + vulnerability_reports=domain.vulnerability_reports, + case_participants=domain.case_participants, + actor_participant_index=domain.actor_participant_index, + notes=domain.notes, + active_embargo=domain.active_embargo, + proposed_embargoes=domain.proposed_embargoes, + ) + + +def domain_case_actor_to_wire(domain: VultronCaseActor) -> CaseActor: + """Convert a ``VultronCaseActor`` domain object to a ``CaseActor`` wire object.""" + return CaseActor( + as_id=domain.as_id, + name=domain.name, + attributed_to=domain.attributed_to, + context=domain.context, + ) + + +def domain_participant_to_wire( + domain: VultronParticipant, +) -> VendorParticipant: + """Convert a ``VultronParticipant`` domain object to a ``VendorParticipant`` wire object.""" + return VendorParticipant( + as_id=domain.as_id, + name=domain.name, + attributed_to=domain.attributed_to, + context=domain.context, + case_roles=domain.case_roles, + accepted_embargo_ids=domain.accepted_embargo_ids, + participant_case_name=domain.participant_case_name, + ) + + +def domain_create_case_activity_to_wire( + domain: VultronCreateCaseActivity, +) -> as_CreateCase: + """Convert a ``VultronCreateCaseActivity`` domain object to a ``CreateCaseActivity`` wire activity.""" + return as_CreateCase( + as_id=domain.as_id, + actor=domain.actor, + object=domain.object, + ) + + +def domain_participant_status_to_wire( + domain: VultronParticipantStatus, +) -> ParticipantStatus: + """Convert a ``VultronParticipantStatus`` domain object to a ``ParticipantStatus`` wire object.""" + return ParticipantStatus( + as_id=domain.as_id, + attributed_to=domain.attributed_to, + context=domain.context, + rm_state=domain.rm_state, + vfd_state=domain.vfd_state, + case_engagement=domain.case_engagement, + embargo_adherence=domain.embargo_adherence, + tracking_id=domain.tracking_id, + ) + + +def domain_report_to_wire(domain: VultronReport) -> VulnerabilityReport: + """Convert a ``VultronReport`` domain object to a ``VulnerabilityReport`` wire object.""" + return VulnerabilityReport( + as_id=domain.as_id, + name=domain.name, + content=domain.content, + attributed_to=domain.attributed_to, + context=domain.context, + ) + + +__all__ = [ + "domain_case_to_wire", + "domain_case_actor_to_wire", + "domain_participant_to_wire", + "domain_create_case_activity_to_wire", + "domain_participant_status_to_wire", + "domain_report_to_wire", +] diff --git a/vultron/as_vocab/__init__.py b/vultron/wire/as2/vocab/__init__.py similarity index 100% rename from vultron/as_vocab/__init__.py rename to vultron/wire/as2/vocab/__init__.py diff --git a/vultron/as_vocab/activities/__init__.py b/vultron/wire/as2/vocab/activities/__init__.py similarity index 100% rename from vultron/as_vocab/activities/__init__.py rename to vultron/wire/as2/vocab/activities/__init__.py diff --git a/vultron/as_vocab/activities/actor.py b/vultron/wire/as2/vocab/activities/actor.py similarity index 64% rename from vultron/as_vocab/activities/actor.py rename to vultron/wire/as2/vocab/activities/actor.py index 60215217..6d27616d 100644 --- a/vultron/as_vocab/activities/actor.py +++ b/vultron/wire/as2/vocab/activities/actor.py @@ -17,44 +17,49 @@ from pydantic import Field -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Accept, as_Offer, as_Reject, ) -from vultron.as_vocab.base.objects.actors import as_ActorRef -from vultron.as_vocab.objects.vulnerability_case import ( +from vultron.wire.as2.vocab.base.objects.actors import as_ActorRef +from vultron.wire.as2.vocab.objects.vulnerability_case import ( VulnerabilityCaseRef, ) -class RecommendActor(as_Offer): +class RecommendActorActivity(as_Offer): """The actor is recommending another actor to a case.""" as_object: as_ActorRef = Field(default=None, alias="object") target: VulnerabilityCaseRef = None -class AcceptActorRecommendation(as_Accept): +class AcceptActorRecommendationActivity(as_Accept): """The case owner is accepting a recommendation to add an actor to the case. - - as_object: the RecommendActor offer being accepted - Should be followed by an RmInviteToCase activity targeted at the recommended actor. + - as_object: the RecommendActorActivity offer being accepted + Should be followed by an RmInviteToCaseActivity activity targeted at the recommended actor. """ - as_object: "RecommendActor | str | None" = Field( + as_object: "RecommendActorActivity | str | None" = Field( default=None, alias="object" ) target: VulnerabilityCaseRef = None -class RejectActorRecommendation(as_Reject): +class RejectActorRecommendationActivity(as_Reject): """The case owner is rejecting a recommendation to add an actor to the case. - - as_object: the RecommendActor offer being rejected + - as_object: the RecommendActorActivity offer being rejected """ - as_object: "RecommendActor | str | None" = Field( + as_object: "RecommendActorActivity | str | None" = Field( default=None, alias="object" ) target: VulnerabilityCaseRef = None + + +# NOTE: Old non-suffixed names were removed intentionally. Use the +# RecommendActorActivity / AcceptActorRecommendationActivity / +# RejectActorRecommendationActivity class names. diff --git a/vultron/as_vocab/activities/case.py b/vultron/wire/as2/vocab/activities/case.py similarity index 74% rename from vultron/as_vocab/activities/case.py rename to vultron/wire/as2/vocab/activities/case.py index 4fc887f3..d7e8c414 100644 --- a/vultron/as_vocab/activities/case.py +++ b/vultron/wire/as2/vocab/activities/case.py @@ -20,8 +20,8 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Accept, as_Add, as_Create, @@ -33,11 +33,13 @@ as_Reject, as_Update, ) -from vultron.as_vocab.base.objects.actors import as_ActorRef -from vultron.as_vocab.base.objects.object_types import as_NoteRef -from vultron.as_vocab.objects.case_status import CaseStatusRef -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCaseRef -from vultron.as_vocab.objects.vulnerability_report import ( +from vultron.wire.as2.vocab.base.objects.actors import as_ActorRef +from vultron.wire.as2.vocab.base.objects.object_types import as_NoteRef +from vultron.wire.as2.vocab.objects.case_status import CaseStatusRef +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCaseRef, +) +from vultron.wire.as2.vocab.objects.vulnerability_report import ( VulnerabilityReportRef, ) @@ -46,7 +48,7 @@ ######################################################################################## -class AddReportToCase(as_Add): +class AddReportToCaseActivity(as_Add): """Add a VulnerabilityReport to a VulnerabilityCase as_object: VulnerabilityReport target: VulnerabilityCase @@ -57,11 +59,11 @@ class AddReportToCase(as_Add): # add CaseParticipant to VulnerabilityCase -# see AddParticipantToCase in case_participant.py +# see AddParticipantToCaseActivity in case_participant.py # add CaseStatus to VulnerabilityCase -class AddStatusToCase(as_Add): +class AddStatusToCaseActivity(as_Add): """Add a CaseStatus to a VulnerabilityCase. This should only be performed by the case owner. Other case participants can add a case status to their participant record, which the case @@ -80,7 +82,7 @@ class AddStatusToCase(as_Add): # create a VulnerabilityCase -class CreateCase(as_Create): +class CreateCaseActivity(as_Create): """Create a VulnerabilityCase. as_object: VulnerabilityCase """ @@ -88,7 +90,7 @@ class CreateCase(as_Create): as_object: VulnerabilityCaseRef = Field(None, alias="object") -class CreateCaseStatus(as_Create): +class CreateCaseStatusActivity(as_Create): """Create a CaseStatus. as_object: CaseStatus """ @@ -97,7 +99,7 @@ class CreateCaseStatus(as_Create): # Add a Note to a VulnerabilityCase -class AddNoteToCase(as_Add): +class AddNoteToCaseActivity(as_Add): """Add a Note to a VulnerabilityCase. as_object: Note target: VulnerabilityCase @@ -108,7 +110,7 @@ class AddNoteToCase(as_Add): # update a VulnerabilityCase -class UpdateCase(as_Update): +class UpdateCaseActivity(as_Update): """Update a VulnerabilityCase. as_object: VulnerabilityCase """ @@ -122,7 +124,7 @@ class UpdateCase(as_Update): # join a case -class RmEngageCase(as_Join): +class RmEngageCaseActivity(as_Join): """The actor is has joined (i.e., is actively working on) a case. This represents the Vultron Message Type RA, and indicates that the actor is now in the RM.ACCEPTED state. as_object: VulnerabilityCase @@ -131,20 +133,20 @@ class RmEngageCase(as_Join): as_object: VulnerabilityCaseRef = Field(None, alias="object") -class RmDeferCase(as_Ignore): +class RmDeferCaseActivity(as_Ignore): """The actor is deferring a case. This implies that the actor is no longer actively working on the case. Deferring a case does not imply that the actor is abandoning the case entirely, it just means that the actor is no longer actively working on it. This represents the Vultron Message Type RD, and indicates that the actor is now in the RM.DEFERRED state. - Contrast with RmCloseCase, which indicates that the actor is abandoning the case entirely. + Contrast with RmCloseCaseActivity, which indicates that the actor is abandoning the case entirely. as_object: VulnerabilityCase """ as_object: VulnerabilityCaseRef = Field(None, alias="object") -class RmCloseCase(as_Leave): +class RmCloseCaseActivity(as_Leave): """The actor is ending their participation in the case and closing their local copy of the case. This corresponds to the Vultron RC message type. Case closure is considered a permanent Leave from the case. @@ -156,7 +158,7 @@ class RmCloseCase(as_Leave): as_object: VulnerabilityCaseRef = Field(None, alias="object") -class OfferCaseOwnershipTransfer(as_Offer): +class OfferCaseOwnershipTransferActivity(as_Offer): """The actor is offering to transfer ownership of the case to another actor. as_object: VulnerabilityCase target: as_Actor @@ -166,32 +168,32 @@ class OfferCaseOwnershipTransfer(as_Offer): target: as_ActorRef = None -class AcceptCaseOwnershipTransfer(as_Accept): +class AcceptCaseOwnershipTransferActivity(as_Accept): """The actor is accepting an offer to transfer ownership of the case. - - as_object: the OfferCaseOwnershipTransfer being accepted + - as_object: the OfferCaseOwnershipTransferActivity being accepted """ - as_object: OfferCaseOwnershipTransfer | str | None = Field( + as_object: OfferCaseOwnershipTransferActivity | str | None = Field( None, alias="object" ) -class RejectCaseOwnershipTransfer(as_Reject): +class RejectCaseOwnershipTransferActivity(as_Reject): """The actor is rejecting an offer to transfer ownership of the case. - - as_object: the OfferCaseOwnershipTransfer being rejected + - as_object: the OfferCaseOwnershipTransferActivity being rejected """ - as_object: OfferCaseOwnershipTransfer | str | None = Field( + as_object: OfferCaseOwnershipTransferActivity | str | None = Field( None, alias="object" ) -class RmInviteToCase(as_Invite): +class RmInviteToCaseActivity(as_Invite): """The actor is inviting another actor to a case. This corresponds to the Vultron Message Type RS when a case already exists. - See also RmSubmitReport for the scenario when a case does not exist yet. + See also RmSubmitReportActivity for the scenario when a case does not exist yet. as_object: the Actor being invited target: VulnerabilityCase """ @@ -200,25 +202,25 @@ class RmInviteToCase(as_Invite): target: VulnerabilityCaseRef = None -RmInviteToCaseRef: TypeAlias = ActivityStreamRef[RmInviteToCase] +RmInviteToCaseRef: TypeAlias = ActivityStreamRef[RmInviteToCaseActivity] -class RmAcceptInviteToCase(as_Accept): +class RmAcceptInviteToCaseActivity(as_Accept): """The actor is accepting an invitation to a case. This corresponds to the Vultron Message Type RV when the case already exists. - See also RmValidateReport for the scenario when the case does not exist yet. - as_object: the RmInviteToCase being accepted + See also RmValidateReportActivity for the scenario when the case does not exist yet. + as_object: the RmInviteToCaseActivity being accepted """ as_object: RmInviteToCaseRef = Field(None, alias="object") -class RmRejectInviteToCase(as_Reject): +class RmRejectInviteToCaseActivity(as_Reject): """The actor is rejecting an invitation to a case. This corresponds to the Vultron Message Type RI when the case already exists. - See also RmInvalidateReport for the scenario when the case does not exist yet. + See also RmInvalidateReportActivity for the scenario when the case does not exist yet. - `as_object`: the `RmInviteToCase` being rejected + `as_object`: the `RmInviteToCaseActivity` being rejected """ as_object: RmInviteToCaseRef = Field(None, alias="object") diff --git a/vultron/as_vocab/activities/case_participant.py b/vultron/wire/as2/vocab/activities/case_participant.py similarity index 84% rename from vultron/as_vocab/activities/case_participant.py rename to vultron/wire/as2/vocab/activities/case_participant.py index 3ab87ed4..b82fe9db 100644 --- a/vultron/as_vocab/activities/case_participant.py +++ b/vultron/wire/as2/vocab/activities/case_participant.py @@ -20,18 +20,20 @@ from pydantic import Field, model_validator -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Add, as_Create, as_Remove, ) -from vultron.as_vocab.base.utils import name_of -from vultron.as_vocab.objects.case_participant import CaseParticipantRef -from vultron.as_vocab.objects.case_status import ParticipantStatusRef -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCaseRef +from vultron.wire.as2.vocab.base.utils import name_of +from vultron.wire.as2.vocab.objects.case_participant import CaseParticipantRef +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatusRef +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCaseRef, +) -class CreateParticipant(as_Create): +class CreateParticipantActivity(as_Create): """Create a new CaseParticipant""" as_object: CaseParticipantRef = Field(None, alias="object") @@ -68,7 +70,7 @@ def set_name(self): return self -class CreateStatusForParticipant(as_Create): +class CreateStatusForParticipantActivity(as_Create): """Create a new CaseStatus for a CaseParticipant""" as_object: ParticipantStatusRef = Field(None, alias="object") @@ -76,7 +78,7 @@ class CreateStatusForParticipant(as_Create): # add CaseStatus to CaseParticipant -class AddStatusToParticipant(as_Add): +class AddStatusToParticipantActivity(as_Add): """Add a CaseStatus to a CaseParticipant as_object: CaseStatus target: CaseParticipant @@ -86,7 +88,7 @@ class AddStatusToParticipant(as_Add): target: CaseParticipantRef = None -class AddParticipantToCase(as_Add): +class AddParticipantToCaseActivity(as_Add): """Add a CaseParticipant to a VulnerabilityCase as_object: CaseParticipant target: VulnerabilityCase @@ -96,7 +98,7 @@ class AddParticipantToCase(as_Add): target: VulnerabilityCaseRef = None -class RemoveParticipantFromCase(as_Remove): +class RemoveParticipantFromCaseActivity(as_Remove): """Remove a CaseParticipant from a VulnerabilityCase. This should only be performed by the case owner. as_object: CaseParticipant diff --git a/vultron/as_vocab/activities/embargo.py b/vultron/wire/as2/vocab/activities/embargo.py similarity index 78% rename from vultron/as_vocab/activities/embargo.py rename to vultron/wire/as2/vocab/activities/embargo.py index 5d79961b..e4b29464 100644 --- a/vultron/as_vocab/activities/embargo.py +++ b/vultron/wire/as2/vocab/activities/embargo.py @@ -19,11 +19,11 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.activities.intransitive import ( +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.activities.intransitive import ( as_Question, ) -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Accept, as_Add, as_Announce, @@ -31,13 +31,15 @@ as_Reject, as_Remove, ) -from vultron.as_vocab.objects.embargo_event import ( +from vultron.wire.as2.vocab.objects.embargo_event import ( EmbargoEventRef, ) -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCaseRef +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCaseRef, +) -class EmProposeEmbargo(as_Invite): +class EmProposeEmbargoActivity(as_Invite): """The actor is proposing an embargo on the case. This corresponds to the Vultron Message Types EP and EV as_object: EmbargoEvent @@ -47,15 +49,15 @@ class EmProposeEmbargo(as_Invite): context: VulnerabilityCaseRef = None -EmProposeEmbargoRef: TypeAlias = ActivityStreamRef[EmProposeEmbargo] +EmProposeEmbargoRef: TypeAlias = ActivityStreamRef[EmProposeEmbargoActivity] -class EmAcceptEmbargo(as_Accept): +class EmAcceptEmbargoActivity(as_Accept): """The actor is accepting an embargo proposal. This corresponds to the Vultron Message Types EA and EC. Per ActivityStreams convention: Accept(object=) — the actor accepts the proposal activity itself, not the EmbargoEvent being proposed. - as_object: the EmProposeEmbargo activity being accepted + as_object: the EmProposeEmbargoActivity activity being accepted context: the VulnerabilityCase for which the embargo was proposed """ @@ -63,12 +65,12 @@ class EmAcceptEmbargo(as_Accept): context: VulnerabilityCaseRef = None -class EmRejectEmbargo(as_Reject): +class EmRejectEmbargoActivity(as_Reject): """The actor is rejecting an embargo proposal. This corresponds to the Vultron Message Types ER and EJ. Per ActivityStreams convention: Reject(object=) — the actor rejects the proposal activity itself, not the EmbargoEvent being proposed. - as_object: the EmProposeEmbargo activity being rejected + as_object: the EmProposeEmbargoActivity activity being rejected context: the VulnerabilityCase for which the embargo was proposed """ @@ -76,9 +78,9 @@ class EmRejectEmbargo(as_Reject): context: VulnerabilityCaseRef = None -class ChoosePreferredEmbargo(as_Question): +class ChoosePreferredEmbargoActivity(as_Question): """The case owner is asking the participants to indicate their embargo preferences from among the proposed embargoes. - Case participants should respond with an EmAcceptEmbargo or EmRejectEmbargo activity for each proposed embargo. + Case participants should respond with an EmAcceptEmbargoActivity or EmRejectEmbargoActivity activity for each proposed embargo. Either anyOf or oneOf should be specified, but not both. The Case owner will then need to decide which embargo to make active on the case. """ @@ -89,12 +91,12 @@ class ChoosePreferredEmbargo(as_Question): one_of: Sequence[EmbargoEventRef] | None = None -class ActivateEmbargo(as_Add): +class ActivateEmbargoActivity(as_Add): """The case owner is activating an embargo on the case. This corresponds to the Vultron Message Types EA and EC at the case level as_object: the EmbargoEvent being activated target: the VulnerabilityCase for which the EmbargoEvent was proposed - in_reply_to: the EmProposeEmbargo activity that proposed the EmbargoEvent + in_reply_to: the EmProposeEmbargoActivity activity that proposed the EmbargoEvent """ as_object: EmbargoEventRef = Field(default=None, alias="object") @@ -102,18 +104,18 @@ class ActivateEmbargo(as_Add): in_reply_to: EmProposeEmbargoRef = None -class AddEmbargoToCase(as_Add): +class AddEmbargoToCaseActivity(as_Add): """Add an EmbargoEvent to a case. This should only be performed by the case owner. For use when the case owner is activating an embargo on the case without first proposing it to the participants. - See ActivateEmbargo for use when the case owner is activating an embargo on the case - in response to a previous EmProposeEmbargo activity. + See ActivateEmbargoActivity for use when the case owner is activating an embargo on the case + in response to a previous EmProposeEmbargoActivity activity. """ as_object: EmbargoEventRef = Field(default=None, alias="object") target: VulnerabilityCaseRef = None -class AnnounceEmbargo(as_Announce): +class AnnounceEmbargoActivity(as_Announce): """The case owner is announcing an embargo on the case. as_object: the EmbargoEvent being announced context: the VulnerabilityCase for which the EmbargoEvent is active @@ -125,7 +127,7 @@ class AnnounceEmbargo(as_Announce): # remove EmbargoEvent from proposedEmbargoes of VulnerabilityCase # todo: should proposedEmbargoes be its own collection object that can then be used as the origin here? -class RemoveEmbargoFromCase(as_Remove): +class RemoveEmbargoFromCaseActivity(as_Remove): """Remove an EmbargoEvent from the proposedEmbargoes of a VulnerabilityCase. This should only be performed by the case owner. as_object: EmbargoEvent diff --git a/vultron/as_vocab/activities/report.py b/vultron/wire/as2/vocab/activities/report.py similarity index 80% rename from vultron/as_vocab/activities/report.py rename to vultron/wire/as2/vocab/activities/report.py index 65196c8c..f6c27fdd 100644 --- a/vultron/as_vocab/activities/report.py +++ b/vultron/wire/as2/vocab/activities/report.py @@ -20,8 +20,8 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.activities.transitive import ( +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.activities.transitive import ( as_Accept, as_Create, as_Offer, @@ -29,30 +29,30 @@ as_Reject, as_TentativeReject, ) -from vultron.as_vocab.objects.vulnerability_report import ( +from vultron.wire.as2.vocab.objects.vulnerability_report import ( VulnerabilityReportRef, ) OfferRef: TypeAlias = ActivityStreamRef[as_Offer] -class RmCreateReport(as_Create): +class RmCreateReportActivity(as_Create): """The actor is creating a report.""" as_object: VulnerabilityReportRef = Field(default=None, alias="object") -class RmSubmitReport(as_Offer): +class RmSubmitReportActivity(as_Offer): """The actor is submitting a report to another actor This corresponds to the Vultron RS message type when no case exists. - See also RmInviteToCase for the scenario when a case already exists. + See also RmInviteToCaseActivity for the scenario when a case already exists. as_object: VulnerabilityReport """ as_object: VulnerabilityReportRef = Field(default=None, alias="object") -class RmReadReport(as_Read): +class RmReadReportActivity(as_Read): """The actor has read a report. This corresponds to the Vultron Message Type RK when no case exists. as_object: VulnerabilityReport @@ -61,7 +61,7 @@ class RmReadReport(as_Read): as_object: VulnerabilityReportRef = Field(default=None, alias="object") -class RmValidateReport(as_Accept): +class RmValidateReportActivity(as_Accept): """The actor has validated a report. Corresponds to the Vultron Message Type RV when no case exists. This should be followed by a Create(VulnerabilityCase) activity. @@ -71,21 +71,21 @@ class RmValidateReport(as_Accept): as_object: OfferRef = Field(default=None, alias="object") -class RmInvalidateReport(as_TentativeReject): +class RmInvalidateReportActivity(as_TentativeReject): """The actor has invalidated a report. Corresponds to the Vultron Message Type RI when no case exists. - See also RmRejectInviteToCase for the scenario when a case already exists. + See also RmRejectInviteToCaseActivity for the scenario when a case already exists. as_object: an as_Offer wrapping a VulnerabilityReport """ as_object: OfferRef = Field(default=None, alias="object") -class RmCloseReport(as_Reject): +class RmCloseReportActivity(as_Reject): """The actor is closing the report. This corresponds to the Vultron Message Type RC when no case exists. It can only be emitted when the report is in the RM.INVALID state, because anything past that will - have an associated VulnerabilityCase object, and closure of the case falls to the RmCloseCase activity. + have an associated VulnerabilityCase object, and closure of the case falls to the RmCloseCaseActivity activity. as_object: an as_Offer wrapping a VulnerabilityReport """ diff --git a/vultron/as_vocab/base/__init__.py b/vultron/wire/as2/vocab/base/__init__.py similarity index 100% rename from vultron/as_vocab/base/__init__.py rename to vultron/wire/as2/vocab/base/__init__.py diff --git a/vultron/as_vocab/base/base.py b/vultron/wire/as2/vocab/base/base.py similarity index 97% rename from vultron/as_vocab/base/base.py rename to vultron/wire/as2/vocab/base/base.py index 3304215e..0147827b 100644 --- a/vultron/as_vocab/base/base.py +++ b/vultron/wire/as2/vocab/base/base.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, Field, model_validator, ConfigDict from pydantic.alias_generators import to_camel -from vultron.as_vocab.base.utils import generate_new_id +from vultron.wire.as2.vocab.base.utils import generate_new_id ACTIVITY_STREAMS_NS = "https://www.w3.org/ns/activitystreams" diff --git a/vultron/as_vocab/base/dt_utils.py b/vultron/wire/as2/vocab/base/dt_utils.py similarity index 100% rename from vultron/as_vocab/base/dt_utils.py rename to vultron/wire/as2/vocab/base/dt_utils.py diff --git a/vultron/as_vocab/base/errors.py b/vultron/wire/as2/vocab/base/errors.py similarity index 100% rename from vultron/as_vocab/base/errors.py rename to vultron/wire/as2/vocab/base/errors.py diff --git a/vultron/as_vocab/base/links.py b/vultron/wire/as2/vocab/base/links.py similarity index 95% rename from vultron/as_vocab/base/links.py rename to vultron/wire/as2/vocab/base/links.py index 15d74aba..7b9bdf6f 100644 --- a/vultron/as_vocab/base/links.py +++ b/vultron/wire/as2/vocab/base/links.py @@ -18,8 +18,8 @@ from typing import TypeVar, TypeAlias -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.registry import activitystreams_link +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.registry import activitystreams_link @activitystreams_link diff --git a/vultron/as_vocab/base/objects/__init__.py b/vultron/wire/as2/vocab/base/objects/__init__.py similarity index 100% rename from vultron/as_vocab/base/objects/__init__.py rename to vultron/wire/as2/vocab/base/objects/__init__.py diff --git a/vultron/as_vocab/base/objects/activities/__init__.py b/vultron/wire/as2/vocab/base/objects/activities/__init__.py similarity index 100% rename from vultron/as_vocab/base/objects/activities/__init__.py rename to vultron/wire/as2/vocab/base/objects/activities/__init__.py diff --git a/vultron/as_vocab/base/objects/activities/base.py b/vultron/wire/as2/vocab/base/objects/activities/base.py similarity index 87% rename from vultron/as_vocab/base/objects/activities/base.py rename to vultron/wire/as2/vocab/base/objects/activities/base.py index 86d179d7..06fba0c8 100644 --- a/vultron/as_vocab/base/objects/activities/base.py +++ b/vultron/wire/as2/vocab/base/objects/activities/base.py @@ -18,11 +18,11 @@ from pydantic import Field -from vultron.as_vocab.base.links import as_Link -from vultron.as_vocab.base.objects.actors import as_ActorRef -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.base.registry import activitystreams_activity -from vultron.enums import as_ObjectType as O_type +from vultron.wire.as2.vocab.base.links import as_Link +from vultron.wire.as2.vocab.base.objects.actors import as_ActorRef +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.registry import activitystreams_activity +from vultron.wire.as2.enums import as_ObjectType as O_type @activitystreams_activity diff --git a/vultron/as_vocab/base/objects/activities/errors.py b/vultron/wire/as2/vocab/base/objects/activities/errors.py similarity index 95% rename from vultron/as_vocab/base/objects/activities/errors.py rename to vultron/wire/as2/vocab/base/objects/activities/errors.py index 3dc617ac..779c6361 100644 --- a/vultron/as_vocab/base/objects/activities/errors.py +++ b/vultron/wire/as2/vocab/base/objects/activities/errors.py @@ -14,7 +14,7 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.base.objects.errors import ( +from vultron.wire.as2.vocab.base.objects.errors import ( ActivityVocabularyObjectError, ) diff --git a/vultron/as_vocab/base/objects/activities/intransitive.py b/vultron/wire/as2/vocab/base/objects/activities/intransitive.py similarity index 87% rename from vultron/as_vocab/base/objects/activities/intransitive.py rename to vultron/wire/as2/vocab/base/objects/activities/intransitive.py index c4b9717f..9f68ebd6 100644 --- a/vultron/as_vocab/base/objects/activities/intransitive.py +++ b/vultron/wire/as2/vocab/base/objects/activities/intransitive.py @@ -19,13 +19,13 @@ from pydantic import Field -from vultron.enums import as_IntransitiveActivityType as IA_type -from vultron.as_vocab.base.links import as_Link -from vultron.as_vocab.base.objects.activities.base import ( +from vultron.wire.as2.enums import as_IntransitiveActivityType as IA_type +from vultron.wire.as2.vocab.base.links import as_Link +from vultron.wire.as2.vocab.base.objects.activities.base import ( as_Activity as Activity, ) -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.base.registry import activitystreams_activity +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.registry import activitystreams_activity @activitystreams_activity @@ -72,7 +72,7 @@ class as_Question(as_IntransitiveActivity): def main(): - from vultron.as_vocab.base.utils import print_activity_examples + from vultron.wire.as2.vocab.base.utils import print_activity_examples print_activity_examples() diff --git a/vultron/as_vocab/base/objects/activities/transitive.py b/vultron/wire/as2/vocab/base/objects/activities/transitive.py similarity index 96% rename from vultron/as_vocab/base/objects/activities/transitive.py rename to vultron/wire/as2/vocab/base/objects/activities/transitive.py index fba68eb4..e2009012 100644 --- a/vultron/as_vocab/base/objects/activities/transitive.py +++ b/vultron/wire/as2/vocab/base/objects/activities/transitive.py @@ -16,13 +16,13 @@ from pydantic import Field, model_validator -from vultron.enums import as_TransitiveActivityType as TA_type -from vultron.as_vocab.base.objects.activities.base import ( +from vultron.wire.as2.enums import as_TransitiveActivityType as TA_type +from vultron.wire.as2.vocab.base.objects.activities.base import ( as_Activity as Activity, ) -from vultron.as_vocab.base.objects.base import as_ObjectRef -from vultron.as_vocab.base.registry import activitystreams_activity -from vultron.as_vocab.base.utils import name_of +from vultron.wire.as2.vocab.base.objects.base import as_ObjectRef +from vultron.wire.as2.vocab.base.registry import activitystreams_activity +from vultron.wire.as2.vocab.base.utils import name_of @activitystreams_activity @@ -293,7 +293,7 @@ class as_Read(as_TransitiveActivity): def main(): - from vultron.as_vocab.base.utils import print_activity_examples + from vultron.wire.as2.vocab.base.utils import print_activity_examples print_activity_examples() diff --git a/vultron/as_vocab/base/objects/actors.py b/vultron/wire/as2/vocab/base/objects/actors.py similarity index 92% rename from vultron/as_vocab/base/objects/actors.py rename to vultron/wire/as2/vocab/base/objects/actors.py index 4a87a524..b5be6ce3 100644 --- a/vultron/as_vocab/base/objects/actors.py +++ b/vultron/wire/as2/vocab/base/objects/actors.py @@ -18,14 +18,14 @@ from pydantic import Field, model_validator -from vultron.enums import as_ActorType as A_type -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.base.objects.collections import ( +from vultron.wire.as2.enums import as_ActorType as A_type +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.objects.collections import ( as_Collection, as_OrderedCollection, ) -from vultron.as_vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.registry import activitystreams_object @activitystreams_object @@ -131,7 +131,7 @@ class as_Person(as_Actor): def main(): - from vultron.as_vocab.base.utils import print_object_examples + from vultron.wire.as2.vocab.base.utils import print_object_examples print_object_examples() diff --git a/vultron/as_vocab/base/objects/base.py b/vultron/wire/as2/vocab/base/objects/base.py similarity index 96% rename from vultron/as_vocab/base/objects/base.py rename to vultron/wire/as2/vocab/base/objects/base.py index 9135cef2..b858705a 100644 --- a/vultron/as_vocab/base/objects/base.py +++ b/vultron/wire/as2/vocab/base/objects/base.py @@ -20,11 +20,11 @@ import isodate from pydantic import field_serializer, field_validator, Field -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.dt_utils import ( +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.dt_utils import ( now_utc, ) -from vultron.as_vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.links import ActivityStreamRef class as_Object(as_Base): diff --git a/vultron/as_vocab/base/objects/collections.py b/vultron/wire/as2/vocab/base/objects/collections.py similarity index 92% rename from vultron/as_vocab/base/objects/collections.py rename to vultron/wire/as2/vocab/base/objects/collections.py index aff9e1de..9d85113e 100644 --- a/vultron/as_vocab/base/objects/collections.py +++ b/vultron/wire/as2/vocab/base/objects/collections.py @@ -18,9 +18,9 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.base import as_Object, as_ObjectRef -from vultron.as_vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.base import as_Object, as_ObjectRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object @activitystreams_object @@ -96,7 +96,7 @@ class as_OrderedCollectionPage(as_OrderedCollection, as_CollectionPage): def main(): - from vultron.as_vocab.base.utils import print_object_examples + from vultron.wire.as2.vocab.base.utils import print_object_examples print_object_examples() diff --git a/vultron/as_vocab/base/objects/errors.py b/vultron/wire/as2/vocab/base/objects/errors.py similarity index 93% rename from vultron/as_vocab/base/objects/errors.py rename to vultron/wire/as2/vocab/base/objects/errors.py index b17ac62e..f1e0f580 100644 --- a/vultron/as_vocab/base/objects/errors.py +++ b/vultron/wire/as2/vocab/base/objects/errors.py @@ -14,7 +14,7 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.base.errors import ActivityVocabularyError +from vultron.wire.as2.vocab.base.errors import ActivityVocabularyError class ActivityVocabularyObjectError(ActivityVocabularyError): diff --git a/vultron/as_vocab/base/objects/object_types.py b/vultron/wire/as2/vocab/base/objects/object_types.py similarity index 94% rename from vultron/as_vocab/base/objects/object_types.py rename to vultron/wire/as2/vocab/base/objects/object_types.py index c80517bd..8f04fa45 100644 --- a/vultron/as_vocab/base/objects/object_types.py +++ b/vultron/wire/as2/vocab/base/objects/object_types.py @@ -19,10 +19,10 @@ from pydantic import Field -from vultron.enums import as_ObjectType as O_type -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.base import as_Object, as_ObjectRef -from vultron.as_vocab.base.registry import activitystreams_object +from vultron.wire.as2.enums import as_ObjectType as O_type +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.base import as_Object, as_ObjectRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object @activitystreams_object @@ -152,7 +152,7 @@ class as_Place(as_Object): def main(): - from vultron.as_vocab.base.utils import print_object_examples + from vultron.wire.as2.vocab.base.utils import print_object_examples print_object_examples() diff --git a/vultron/as_vocab/base/registry.py b/vultron/wire/as2/vocab/base/registry.py similarity index 100% rename from vultron/as_vocab/base/registry.py rename to vultron/wire/as2/vocab/base/registry.py diff --git a/vultron/as_vocab/base/types.py b/vultron/wire/as2/vocab/base/types.py similarity index 100% rename from vultron/as_vocab/base/types.py rename to vultron/wire/as2/vocab/base/types.py diff --git a/vultron/as_vocab/base/utils.py b/vultron/wire/as2/vocab/base/utils.py similarity index 80% rename from vultron/as_vocab/base/utils.py rename to vultron/wire/as2/vocab/base/utils.py index 2214f656..ef56cd24 100644 --- a/vultron/as_vocab/base/utils.py +++ b/vultron/wire/as2/vocab/base/utils.py @@ -19,6 +19,8 @@ import uuid from typing import Any +URN_UUID_PREFIX = "urn:uuid:" + def name_of(obj: Any) -> str: """Get the name of an object if it has one, otherwise return the object itself @@ -61,14 +63,21 @@ def exclude_if_empty(value: Any) -> bool: def generate_new_id(prefix: str | None = None) -> str: - """Generate a new ID for an object + """Generate a new URI-form ID for an object. + + Returns a ``urn:uuid:`` URI by default, which is a valid AS2 identifier + and requires no HTTP server configuration. When a *prefix* is supplied the + UUID is appended to it with a ``/`` separator, allowing callers to produce + HTTPS-style identifiers (e.g. ``https://example.org/reports/{uuid}``). Returns: - the new ID + a URI-form ID string """ - _id = str(uuid.uuid4()) + _uuid = str(uuid.uuid4()) if prefix is not None: - _id = f"{prefix}/{_id}" + _id = f"{prefix}/{_uuid}" + else: + _id = f"{URN_UUID_PREFIX}{_uuid}" return _id @@ -85,7 +94,7 @@ def _print_examples(d) -> None: def print_object_examples() -> None: """Print out empty examples of the classes in the given module""" - from vultron.as_vocab import VOCABULARY + from vultron.wire.as2.vocab import VOCABULARY object_types = VOCABULARY.objects _print_examples(object_types) @@ -93,7 +102,7 @@ def print_object_examples() -> None: def print_activity_examples(): """Print out empty examples of the classes in the given module""" - from vultron.as_vocab import VOCABULARY + from vultron.wire.as2.vocab import VOCABULARY activity_types = VOCABULARY.activities _print_examples(activity_types) @@ -101,7 +110,7 @@ def print_activity_examples(): def print_link_examples(): """Print out empty examples of the classes in the given module""" - from vultron.as_vocab import VOCABULARY + from vultron.wire.as2.vocab import VOCABULARY link_types = VOCABULARY.links _print_examples(link_types) diff --git a/vultron/as_vocab/errors.py b/vultron/wire/as2/vocab/errors.py similarity index 100% rename from vultron/as_vocab/errors.py rename to vultron/wire/as2/vocab/errors.py diff --git a/vultron/as_vocab/examples/__init__.py b/vultron/wire/as2/vocab/examples/__init__.py similarity index 100% rename from vultron/as_vocab/examples/__init__.py rename to vultron/wire/as2/vocab/examples/__init__.py diff --git a/vultron/as_vocab/examples/_base.py b/vultron/wire/as2/vocab/examples/_base.py similarity index 87% rename from vultron/as_vocab/examples/_base.py rename to vultron/wire/as2/vocab/examples/_base.py index ef1c4959..3d66e1e1 100644 --- a/vultron/as_vocab/examples/_base.py +++ b/vultron/wire/as2/vocab/examples/_base.py @@ -14,11 +14,16 @@ import random from uuid import uuid4 -from vultron.api.v2.datalayer.abc import DataLayer -from vultron.as_vocab.base.base import as_Base -from vultron.as_vocab.base.objects.actors import as_Organization, as_Person -from vultron.as_vocab.objects.vulnerability_case import VulnerabilityCase -from vultron.as_vocab.objects.vulnerability_report import VulnerabilityReport +from vultron.core.ports.datalayer import DataLayer +from vultron.wire.as2.vocab.base.base import as_Base +from vultron.wire.as2.vocab.base.objects.actors import ( + as_Organization, + as_Person, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase +from vultron.wire.as2.vocab.objects.vulnerability_report import ( + VulnerabilityReport, +) _EXAMPLE_BASE_URL = "https://demo.vultron.local/" @@ -104,14 +109,9 @@ def gen_report() -> VulnerabilityReport: return _REPORT -def initialize_examples(datalayer: DataLayer | None = None) -> None: - from vultron.api.v2.datalayer.db_record import Record - from vultron.api.v2.datalayer.tinydb_backend import get_datalayer - - dl = datalayer if datalayer is not None else get_datalayer() +def initialize_examples(datalayer: DataLayer) -> None: for obj in [_FINDER, _VENDOR, _COORDINATOR, _REPORT]: - record = Record.from_obj(obj) - dl.create(record) + datalayer.create(obj) def _strip_published_udpated(obj: as_Base) -> as_Base: diff --git a/vultron/as_vocab/examples/actor.py b/vultron/wire/as2/vocab/examples/actor.py similarity index 80% rename from vultron/as_vocab/examples/actor.py rename to vultron/wire/as2/vocab/examples/actor.py index a5c0f62a..6f3b0205 100644 --- a/vultron/as_vocab/examples/actor.py +++ b/vultron/wire/as2/vocab/examples/actor.py @@ -11,12 +11,12 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.activities.actor import ( - AcceptActorRecommendation, - RecommendActor, - RejectActorRecommendation, +from vultron.wire.as2.vocab.activities.actor import ( + AcceptActorRecommendationActivity, + RecommendActorActivity, + RejectActorRecommendationActivity, ) -from vultron.as_vocab.examples._base import ( +from vultron.wire.as2.vocab.examples._base import ( _CASE, _COORDINATOR, _FINDER, @@ -27,12 +27,12 @@ ) -def recommend_actor() -> RecommendActor: +def recommend_actor() -> RecommendActorActivity: _case = case() _finder = finder() _vendor = vendor() _coordinator = _COORDINATOR - _activity = RecommendActor( + _activity = RecommendActorActivity( actor=_finder.as_id, object=_coordinator.as_id, context=_case.as_id, @@ -43,13 +43,13 @@ def recommend_actor() -> RecommendActor: return _activity -def accept_actor_recommendation() -> AcceptActorRecommendation: +def accept_actor_recommendation() -> AcceptActorRecommendationActivity: _vendor = vendor() _coordinator = _COORDINATOR _finder = finder() _case = case() _recommendation = recommend_actor() - _activity = AcceptActorRecommendation( + _activity = AcceptActorRecommendationActivity( actor=_vendor.as_id, object=_recommendation, context=_case.as_id, @@ -61,13 +61,13 @@ def accept_actor_recommendation() -> AcceptActorRecommendation: return _activity -def reject_actor_recommendation() -> RejectActorRecommendation: +def reject_actor_recommendation() -> RejectActorRecommendationActivity: _vendor = vendor() _coordinator = _COORDINATOR _finder = finder() _case = case() _recommendation = recommend_actor() - _activity = RejectActorRecommendation( + _activity = RejectActorRecommendationActivity( actor=_vendor.as_id, object=_recommendation, context=_case.as_id, diff --git a/vultron/as_vocab/examples/case.py b/vultron/wire/as2/vocab/examples/case.py similarity index 75% rename from vultron/as_vocab/examples/case.py rename to vultron/wire/as2/vocab/examples/case.py index fd0ad310..52cd3cfc 100644 --- a/vultron/as_vocab/examples/case.py +++ b/vultron/wire/as2/vocab/examples/case.py @@ -11,19 +11,19 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.activities.case import ( - AcceptCaseOwnershipTransfer, - AddReportToCase, - CreateCase, - OfferCaseOwnershipTransfer, - RejectCaseOwnershipTransfer, - RmCloseCase, - RmDeferCase, - RmEngageCase, - UpdateCase, +from vultron.wire.as2.vocab.activities.case import ( + AcceptCaseOwnershipTransferActivity, + AddReportToCaseActivity, + CreateCaseActivity, + OfferCaseOwnershipTransferActivity, + RejectCaseOwnershipTransferActivity, + RmCloseCaseActivity, + RmDeferCaseActivity, + RmEngageCaseActivity, + UpdateCaseActivity, ) -from vultron.as_vocab.base.objects.activities.transitive import as_Undo -from vultron.as_vocab.examples._base import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Undo +from vultron.wire.as2.vocab.examples._base import ( _CASE, _COORDINATOR, _REPORT, @@ -32,10 +32,10 @@ gen_report, vendor, ) -from vultron.as_vocab.objects.case_participant import VendorParticipant +from vultron.wire.as2.vocab.objects.case_participant import VendorParticipant -def create_case() -> CreateCase: +def create_case() -> CreateCaseActivity: _case = case() _case.add_report(_REPORT.as_id) participant = VendorParticipant( @@ -43,7 +43,7 @@ def create_case() -> CreateCase: ) _case.add_participant(participant) - activity = CreateCase( + activity = CreateCaseActivity( actor=_VENDOR.as_id, object=_case, content="We've created a case from this report.", @@ -52,12 +52,12 @@ def create_case() -> CreateCase: return activity -def add_report_to_case() -> AddReportToCase: +def add_report_to_case() -> AddReportToCaseActivity: _vendor = vendor() _report = gen_report() _case = case() - activity = AddReportToCase( + activity = AddReportToCaseActivity( actor=_vendor.as_id, object=_report.as_id, target=_case.as_id, @@ -66,11 +66,11 @@ def add_report_to_case() -> AddReportToCase: return activity -def engage_case() -> RmEngageCase: +def engage_case() -> RmEngageCaseActivity: _vendor = vendor() _case = case() - activity = RmEngageCase( + activity = RmEngageCaseActivity( actor=_vendor.as_id, object=_case.as_id, content="We're engaging this case.", @@ -78,11 +78,11 @@ def engage_case() -> RmEngageCase: return activity -def close_case() -> RmCloseCase: +def close_case() -> RmCloseCaseActivity: _vendor = vendor() _case = case() - activity = RmCloseCase( + activity = RmCloseCaseActivity( actor=_vendor.as_id, object=_case.as_id, content="We're closing this case.", @@ -90,11 +90,11 @@ def close_case() -> RmCloseCase: return activity -def defer_case() -> RmDeferCase: +def defer_case() -> RmDeferCaseActivity: _vendor = vendor() _case = case() - activity = RmDeferCase( + activity = RmDeferCaseActivity( actor=_vendor.as_id, object=_case.as_id, content="We're deferring this case.", @@ -116,11 +116,11 @@ def reengage_case() -> as_Undo: return activity -def offer_case_ownership_transfer() -> OfferCaseOwnershipTransfer: +def offer_case_ownership_transfer() -> OfferCaseOwnershipTransferActivity: _vendor = vendor() _case = case() _coordinator = _COORDINATOR - _activity = OfferCaseOwnershipTransfer( + _activity = OfferCaseOwnershipTransferActivity( actor=_vendor.as_id, object=_case, target=_coordinator.as_id, @@ -129,11 +129,11 @@ def offer_case_ownership_transfer() -> OfferCaseOwnershipTransfer: return _activity -def accept_case_ownership_transfer() -> AcceptCaseOwnershipTransfer: +def accept_case_ownership_transfer() -> AcceptCaseOwnershipTransferActivity: _case = case() _coordinator = _COORDINATOR _offer = offer_case_ownership_transfer() - _activity = AcceptCaseOwnershipTransfer( + _activity = AcceptCaseOwnershipTransferActivity( actor=_coordinator.as_id, object=_offer, content=f"We're accepting your offer to transfer ownership of case {_case.name} to us.", @@ -141,11 +141,11 @@ def accept_case_ownership_transfer() -> AcceptCaseOwnershipTransfer: return _activity -def reject_case_ownership_transfer() -> RejectCaseOwnershipTransfer: +def reject_case_ownership_transfer() -> RejectCaseOwnershipTransferActivity: _case = case() _coordinator = _COORDINATOR _offer = offer_case_ownership_transfer() - _activity = RejectCaseOwnershipTransfer( + _activity = RejectCaseOwnershipTransferActivity( actor=_coordinator.as_id, object=_offer, content=f"We're declining your offer to transfer ownership of case {_case.name} to us.", @@ -153,11 +153,11 @@ def reject_case_ownership_transfer() -> RejectCaseOwnershipTransfer: return _activity -def update_case() -> UpdateCase: +def update_case() -> UpdateCaseActivity: _case = case() _vendor = vendor() - _activity = UpdateCase( + _activity = UpdateCaseActivity( actor=_vendor.as_id, object=_case.as_id, content="We're updating the case to reflect a transfer of ownership.", diff --git a/vultron/as_vocab/examples/embargo.py b/vultron/wire/as2/vocab/examples/embargo.py similarity index 75% rename from vultron/as_vocab/examples/embargo.py rename to vultron/wire/as2/vocab/examples/embargo.py index d72c56cf..4929c546 100644 --- a/vultron/as_vocab/examples/embargo.py +++ b/vultron/wire/as2/vocab/examples/embargo.py @@ -13,18 +13,23 @@ from datetime import datetime, timedelta -from vultron.as_vocab.activities.embargo import ( - ActivateEmbargo, - AddEmbargoToCase, - AnnounceEmbargo, - ChoosePreferredEmbargo, - EmAcceptEmbargo, - EmProposeEmbargo, - EmRejectEmbargo, - RemoveEmbargoFromCase, +from vultron.wire.as2.vocab.activities.embargo import ( + ActivateEmbargoActivity, + AddEmbargoToCaseActivity, + AnnounceEmbargoActivity, + ChoosePreferredEmbargoActivity, + EmAcceptEmbargoActivity, + EmProposeEmbargoActivity, + EmRejectEmbargoActivity, + RemoveEmbargoFromCaseActivity, ) -from vultron.as_vocab.examples._base import _COORDINATOR, _VENDOR, case, vendor -from vultron.as_vocab.objects.embargo_event import EmbargoEvent +from vultron.wire.as2.vocab.examples._base import ( + _COORDINATOR, + _VENDOR, + case, + vendor, +) +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent def embargo_event(days: int = 90) -> EmbargoEvent: @@ -52,12 +57,12 @@ def embargo_event(days: int = 90) -> EmbargoEvent: return event -def propose_embargo() -> EmProposeEmbargo: +def propose_embargo() -> EmProposeEmbargoActivity: embargo = embargo_event() _case = case() _vendor = vendor() - activity = EmProposeEmbargo( + activity = EmProposeEmbargoActivity( id=f"{_case.as_id}/embargo_proposals/1", actor=_vendor.as_id, object=embargo, @@ -68,7 +73,7 @@ def propose_embargo() -> EmProposeEmbargo: # TODO this seems less like an API call and more like a poll -def choose_preferred_embargo() -> ChoosePreferredEmbargo: +def choose_preferred_embargo() -> ChoosePreferredEmbargoActivity: embargo_list = [ embargo_event(90), embargo_event(45), @@ -76,7 +81,7 @@ def choose_preferred_embargo() -> ChoosePreferredEmbargo: _coordinator = _COORDINATOR _case = case() - activity = ChoosePreferredEmbargo( + activity = ChoosePreferredEmbargoActivity( id="https://vultron.example/cases/1/polls/1", actor=_coordinator.as_id, one_of=embargo_list, @@ -87,10 +92,10 @@ def choose_preferred_embargo() -> ChoosePreferredEmbargo: return activity -def accept_embargo() -> EmAcceptEmbargo: +def accept_embargo() -> EmAcceptEmbargoActivity: proposal = propose_embargo() _vendor = vendor() - activity = EmAcceptEmbargo( + activity = EmAcceptEmbargoActivity( actor=_vendor.as_id, object=proposal, context=proposal.context, @@ -99,10 +104,10 @@ def accept_embargo() -> EmAcceptEmbargo: return activity -def reject_embargo() -> EmRejectEmbargo: +def reject_embargo() -> EmRejectEmbargoActivity: proposal = propose_embargo() _vendor = vendor() - activity = EmRejectEmbargo( + activity = EmRejectEmbargoActivity( actor=_vendor.as_id, object=proposal, context=proposal.context, @@ -111,10 +116,10 @@ def reject_embargo() -> EmRejectEmbargo: return activity -def add_embargo_to_case() -> AddEmbargoToCase: +def add_embargo_to_case() -> AddEmbargoToCaseActivity: _case = case() _vendor = vendor() - activity = AddEmbargoToCase( + activity = AddEmbargoToCaseActivity( actor=_vendor.as_id, object=embargo_event(90), target=_case.as_id, @@ -123,10 +128,10 @@ def add_embargo_to_case() -> AddEmbargoToCase: return activity -def activate_embargo() -> ActivateEmbargo: +def activate_embargo() -> ActivateEmbargoActivity: _case = case() _vendor = vendor() - activity = ActivateEmbargo( + activity = ActivateEmbargoActivity( actor=_vendor.as_id, object=propose_embargo().as_object, target=_case.as_id, @@ -136,11 +141,11 @@ def activate_embargo() -> ActivateEmbargo: return activity -def announce_embargo() -> AnnounceEmbargo: +def announce_embargo() -> AnnounceEmbargoActivity: _vendor = vendor() _case = case() - activity = AnnounceEmbargo( + activity = AnnounceEmbargoActivity( actor=_vendor.as_id, object=embargo_event(90), context=_case.as_id, @@ -149,10 +154,10 @@ def announce_embargo() -> AnnounceEmbargo: return activity -def remove_embargo() -> RemoveEmbargoFromCase: +def remove_embargo() -> RemoveEmbargoFromCaseActivity: _vendor = vendor() _case = case() - activity = RemoveEmbargoFromCase( + activity = RemoveEmbargoFromCaseActivity( actor=_vendor.as_id, object=embargo_event(90), origin=_case.as_id, diff --git a/vultron/as_vocab/examples/note.py b/vultron/wire/as2/vocab/examples/note.py similarity index 81% rename from vultron/as_vocab/examples/note.py rename to vultron/wire/as2/vocab/examples/note.py index f2dfc827..b902647b 100644 --- a/vultron/as_vocab/examples/note.py +++ b/vultron/wire/as2/vocab/examples/note.py @@ -11,10 +11,10 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.activities.case import AddNoteToCase -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.base.objects.object_types import as_Note -from vultron.as_vocab.examples._base import ( +from vultron.wire.as2.vocab.activities.case import AddNoteToCaseActivity +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.base.objects.object_types import as_Note +from vultron.wire.as2.vocab.examples._base import ( _CASE, _FINDER, _VENDOR, @@ -36,13 +36,13 @@ def note() -> as_Note: return _note -def add_note_to_case() -> AddNoteToCase: +def add_note_to_case() -> AddNoteToCaseActivity: _finder = finder() _case = case() _note = note() _note.context = _case.as_id - activity = AddNoteToCase( + activity = AddNoteToCaseActivity( actor=_finder.as_id, object=_note, target=_case.as_id, diff --git a/vultron/as_vocab/examples/participant.py b/vultron/wire/as2/vocab/examples/participant.py similarity index 81% rename from vultron/as_vocab/examples/participant.py rename to vultron/wire/as2/vocab/examples/participant.py index 3cafbdf9..92c7a857 100644 --- a/vultron/as_vocab/examples/participant.py +++ b/vultron/wire/as2/vocab/examples/participant.py @@ -11,17 +11,17 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.activities.case import ( - RmAcceptInviteToCase, - RmInviteToCase, - RmRejectInviteToCase, +from vultron.wire.as2.vocab.activities.case import ( + RmAcceptInviteToCaseActivity, + RmInviteToCaseActivity, + RmRejectInviteToCaseActivity, ) -from vultron.as_vocab.activities.case_participant import ( - AddParticipantToCase, - RemoveParticipantFromCase, +from vultron.wire.as2.vocab.activities.case_participant import ( + AddParticipantToCaseActivity, + RemoveParticipantFromCaseActivity, ) -from vultron.as_vocab.base.objects.activities.transitive import as_Create -from vultron.as_vocab.examples._base import ( +from vultron.wire.as2.vocab.base.objects.activities.transitive import as_Create +from vultron.wire.as2.vocab.examples._base import ( _CASE, _COORDINATOR, _FINDER, @@ -30,19 +30,22 @@ finder, vendor, ) -from vultron.as_vocab.examples.status import case_status, participant_status -from vultron.as_vocab.objects.case_participant import ( +from vultron.wire.as2.vocab.examples.status import ( + case_status, + participant_status, +) +from vultron.wire.as2.vocab.objects.case_participant import ( CaseParticipant, CoordinatorParticipant, FinderReporterParticipant, VendorParticipant, ) -from vultron.as_vocab.objects.case_status import ParticipantStatus +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus from vultron.bt.report_management.states import RM from vultron.case_states.states import CS_vfd -def add_vendor_participant_to_case() -> AddParticipantToCase: +def add_vendor_participant_to_case() -> AddParticipantToCaseActivity: _vendor = vendor() _case = case() @@ -62,7 +65,7 @@ def add_vendor_participant_to_case() -> AddParticipantToCase: ) _vendor_participant.participant_statuses = [_pstatus] - activity = AddParticipantToCase( + activity = AddParticipantToCaseActivity( actor=_vendor.as_id, object=_vendor_participant, target=_case.as_id, @@ -71,7 +74,7 @@ def add_vendor_participant_to_case() -> AddParticipantToCase: return activity -def add_finder_participant_to_case() -> AddParticipantToCase: +def add_finder_participant_to_case() -> AddParticipantToCaseActivity: _vendor = vendor() _case = case() @@ -84,7 +87,7 @@ def add_finder_participant_to_case() -> AddParticipantToCase: context=_case.as_id, ) - activity = AddParticipantToCase( + activity = AddParticipantToCaseActivity( actor=_vendor.as_id, object=_finder_participant, target=_case.as_id, @@ -93,7 +96,7 @@ def add_finder_participant_to_case() -> AddParticipantToCase: return activity -def add_coordinator_participant_to_case() -> AddParticipantToCase: +def add_coordinator_participant_to_case() -> AddParticipantToCaseActivity: _vendor = vendor() _case = case() @@ -106,7 +109,7 @@ def add_coordinator_participant_to_case() -> AddParticipantToCase: context=_case.as_id, ) - activity = AddParticipantToCase( + activity = AddParticipantToCaseActivity( actor=_vendor.as_id, object=_coordinator_participant, target=_case.as_id, @@ -115,11 +118,11 @@ def add_coordinator_participant_to_case() -> AddParticipantToCase: return activity -def rm_invite_to_case() -> RmInviteToCase: +def rm_invite_to_case() -> RmInviteToCaseActivity: _vendor = vendor() _coordinator = _COORDINATOR _case = case() - _activity = RmInviteToCase( + _activity = RmInviteToCaseActivity( id=f"{_case.as_id}/invitation/1", actor=_vendor.as_id, object=_coordinator.as_id, @@ -130,12 +133,12 @@ def rm_invite_to_case() -> RmInviteToCase: return _activity -def accept_invite_to_case() -> RmAcceptInviteToCase: +def accept_invite_to_case() -> RmAcceptInviteToCaseActivity: _vendor = vendor() _coordinator = _COORDINATOR _case = case() _invite = rm_invite_to_case() - _activity = RmAcceptInviteToCase( + _activity = RmAcceptInviteToCaseActivity( actor=_coordinator.as_id, object=_invite, to=_vendor.as_id, @@ -144,12 +147,12 @@ def accept_invite_to_case() -> RmAcceptInviteToCase: return _activity -def reject_invite_to_case() -> RmRejectInviteToCase: +def reject_invite_to_case() -> RmRejectInviteToCaseActivity: _vendor = vendor() _coordinator = _COORDINATOR _case = case() _invite = rm_invite_to_case() - _activity = RmRejectInviteToCase( + _activity = RmRejectInviteToCaseActivity( actor=_coordinator.as_id, object=_invite, to=_vendor.as_id, @@ -206,7 +209,7 @@ def invite_to_case(): _coordinator = _COORDINATOR _vendor = vendor() - activity = RmInviteToCase( + activity = RmInviteToCaseActivity( id=f"{_case.as_id}/invitation/1", actor=_vendor.as_id, object=_coordinator.as_id, @@ -221,7 +224,7 @@ def remove_participant_from_case(): _vendor = vendor() _case = case() coord_p = coordinator_participant() - activity = RemoveParticipantFromCase( + activity = RemoveParticipantFromCaseActivity( actor=_vendor.as_id, object=coord_p.as_id, origin=_case.as_id, diff --git a/vultron/as_vocab/examples/report.py b/vultron/wire/as2/vocab/examples/report.py similarity index 73% rename from vultron/as_vocab/examples/report.py rename to vultron/wire/as2/vocab/examples/report.py index b6a54615..027587ef 100644 --- a/vultron/as_vocab/examples/report.py +++ b/vultron/wire/as2/vocab/examples/report.py @@ -11,46 +11,46 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.activities.report import ( - RmCloseReport, - RmCreateReport, - RmInvalidateReport, - RmReadReport, - RmSubmitReport, - RmValidateReport, +from vultron.wire.as2.vocab.activities.report import ( + RmCloseReportActivity, + RmCreateReportActivity, + RmInvalidateReportActivity, + RmReadReportActivity, + RmSubmitReportActivity, + RmValidateReportActivity, ) -from vultron.as_vocab.examples._base import _FINDER, _REPORT, _VENDOR +from vultron.wire.as2.vocab.examples._base import _FINDER, _REPORT, _VENDOR -def create_report() -> RmCreateReport: +def create_report() -> RmCreateReportActivity: """ In this example, a finder creates a vulnerability report. Example: - >>> RmCreateReport(actor=finder.as_id, id=gen_report) + >>> RmCreateReportActivity(actor=finder.as_id, id=gen_report) """ - activity = RmCreateReport(actor=_FINDER.as_id, object=_REPORT) + activity = RmCreateReportActivity(actor=_FINDER.as_id, object=_REPORT) return activity -def submit_report(verbose=False) -> RmSubmitReport: +def submit_report(verbose=False) -> RmSubmitReportActivity: if verbose: - activity = RmSubmitReport( + activity = RmSubmitReportActivity( actor=_FINDER, object=_REPORT, to=_VENDOR, ) else: - activity = RmSubmitReport( + activity = RmSubmitReportActivity( actor=_FINDER.as_id, object=_REPORT, to=_VENDOR.as_id ) return activity -def read_report() -> RmReadReport: +def read_report() -> RmReadReportActivity: # TODO this should probably change to Read(Offer(Report)) to match the other activities - activity = RmReadReport( + activity = RmReadReportActivity( actor=_VENDOR.as_id, object=_REPORT.as_id, content="We've read the report. We'll get back to you soon.", @@ -58,18 +58,18 @@ def read_report() -> RmReadReport: return activity -def validate_report(verbose: bool = False) -> RmValidateReport: +def validate_report(verbose: bool = False) -> RmValidateReportActivity: _offer = submit_report(verbose=verbose) # Note: you accept the Offer activity that contains the Report, not the Report itself if verbose: - activity = RmValidateReport( + activity = RmValidateReportActivity( actor=_VENDOR, object=_offer, content="We've validated the report. We'll be creating a case shortly.", ) else: - activity = RmValidateReport( + activity = RmValidateReportActivity( actor=_VENDOR.as_id, object=_offer.as_id, content="We've validated the report. We'll be creating a case shortly.", @@ -77,18 +77,18 @@ def validate_report(verbose: bool = False) -> RmValidateReport: return activity -def invalidate_report(verbose: bool = False) -> RmInvalidateReport: +def invalidate_report(verbose: bool = False) -> RmInvalidateReportActivity: _offer = submit_report(verbose=verbose) # Note: you tentative reject the Offer activity that contains the Report, not the Report itself if verbose: - activity = RmInvalidateReport( + activity = RmInvalidateReportActivity( actor=_VENDOR, object=_offer, content="We're declining this report as invalid. If you have a reason we should reconsider, please let us know. Otherwise we'll be closing it shortly.", ) else: - activity = RmInvalidateReport( + activity = RmInvalidateReportActivity( actor=_VENDOR.as_id, object=_offer.as_id, content="We're declining this report as invalid. If you have a reason we should reconsider, please let us know. Otherwise we'll be closing it shortly.", @@ -96,17 +96,17 @@ def invalidate_report(verbose: bool = False) -> RmInvalidateReport: return activity -def close_report(verbose: bool = False) -> RmCloseReport: +def close_report(verbose: bool = False) -> RmCloseReportActivity: # Note: you reject the Offer activity that contains the Report, not the Report itself _offer = submit_report(verbose=verbose) if verbose: - activity = RmCloseReport( + activity = RmCloseReportActivity( actor=_VENDOR, object=_offer, content="We're closing this report.", ) else: - activity = RmCloseReport( + activity = RmCloseReportActivity( actor=_VENDOR.as_id, object=_offer.as_id, content="We're closing this report.", diff --git a/vultron/as_vocab/examples/status.py b/vultron/wire/as2/vocab/examples/status.py similarity index 77% rename from vultron/as_vocab/examples/status.py rename to vultron/wire/as2/vocab/examples/status.py index 40e0727e..d18c58f7 100644 --- a/vultron/as_vocab/examples/status.py +++ b/vultron/wire/as2/vocab/examples/status.py @@ -11,13 +11,19 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.activities.case import AddStatusToCase, CreateCaseStatus -from vultron.as_vocab.activities.case_participant import ( - AddStatusToParticipant, - CreateStatusForParticipant, +from vultron.wire.as2.vocab.activities.case import ( + AddStatusToCaseActivity, + CreateCaseStatusActivity, +) +from vultron.wire.as2.vocab.activities.case_participant import ( + AddStatusToParticipantActivity, + CreateStatusForParticipantActivity, +) +from vultron.wire.as2.vocab.examples._base import _VENDOR, case, vendor +from vultron.wire.as2.vocab.objects.case_status import ( + CaseStatus, + ParticipantStatus, ) -from vultron.as_vocab.examples._base import _VENDOR, case, vendor -from vultron.as_vocab.objects.case_status import CaseStatus, ParticipantStatus from vultron.bt.embargo_management.states import EM from vultron.bt.report_management.states import RM from vultron.case_states.states import CS_pxa, CS_vfd @@ -38,7 +44,7 @@ def create_case_status(): status = case_status() _case = case() - activity = CreateCaseStatus( + activity = CreateCaseStatusActivity( actor=actor.as_id, object=status, context=_case.as_id, @@ -46,11 +52,11 @@ def create_case_status(): return activity -def add_status_to_case() -> AddStatusToCase: +def add_status_to_case() -> AddStatusToCaseActivity: _vendor = vendor() _case = case() _status = case_status() - activity = AddStatusToCase( + activity = AddStatusToCaseActivity( actor=_vendor.as_id, object=_status, target=_case.as_id, @@ -74,18 +80,18 @@ def create_participant_status() -> ParticipantStatus: pstatus = participant_status() _vendor = vendor() - activity = CreateStatusForParticipant( + activity = CreateStatusForParticipantActivity( actor=_vendor.as_id, object=pstatus, ) return activity -def add_status_to_participant() -> AddStatusToParticipant: +def add_status_to_participant() -> AddStatusToParticipantActivity: _vendor = vendor() pstatus = participant_status() - activity = AddStatusToParticipant( + activity = AddStatusToParticipantActivity( actor=_vendor.as_id, object=pstatus, target="https://vultron.example/cases/1/participants/vendor", diff --git a/vultron/as_vocab/examples/vocab_examples.py b/vultron/wire/as2/vocab/examples/vocab_examples.py similarity index 89% rename from vultron/as_vocab/examples/vocab_examples.py rename to vultron/wire/as2/vocab/examples/vocab_examples.py index 56e9a72b..a60bcfc1 100644 --- a/vultron/as_vocab/examples/vocab_examples.py +++ b/vultron/wire/as2/vocab/examples/vocab_examples.py @@ -21,16 +21,16 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.examples._base import * # noqa: F401, F403 -from vultron.as_vocab.examples.actor import * # noqa: F401, F403 -from vultron.as_vocab.examples.case import * # noqa: F401, F403 -from vultron.as_vocab.examples.embargo import * # noqa: F401, F403 -from vultron.as_vocab.examples.note import * # noqa: F401, F403 -from vultron.as_vocab.examples.participant import * # noqa: F401, F403 -from vultron.as_vocab.examples.report import * # noqa: F401, F403 -from vultron.as_vocab.examples.status import * # noqa: F401, F403 - -from vultron.as_vocab.examples._base import ( # noqa: F401 +from vultron.wire.as2.vocab.examples._base import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.actor import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.case import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.embargo import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.note import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.participant import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.report import * # noqa: F401, F403 +from vultron.wire.as2.vocab.examples.status import * # noqa: F401, F403 + +from vultron.wire.as2.vocab.examples._base import ( # noqa: F401 ACTOR_FUNCS, _COORDINATOR, case, @@ -40,12 +40,12 @@ obj_to_file, vendor, ) -from vultron.as_vocab.examples.actor import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.actor import ( # noqa: F401 accept_actor_recommendation, recommend_actor, reject_actor_recommendation, ) -from vultron.as_vocab.examples.case import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.case import ( # noqa: F401 accept_case_ownership_transfer, add_report_to_case, close_case, @@ -57,7 +57,7 @@ reject_case_ownership_transfer, update_case, ) -from vultron.as_vocab.examples.embargo import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.embargo import ( # noqa: F401 accept_embargo, activate_embargo, add_embargo_to_case, @@ -68,12 +68,12 @@ reject_embargo, remove_embargo, ) -from vultron.as_vocab.examples.note import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.note import ( # noqa: F401 add_note_to_case, create_note, note, ) -from vultron.as_vocab.examples.participant import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.participant import ( # noqa: F401 accept_invite_to_case, add_coordinator_participant_to_case, add_finder_participant_to_case, @@ -86,7 +86,7 @@ remove_participant_from_case, rm_invite_to_case, ) -from vultron.as_vocab.examples.report import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.report import ( # noqa: F401 close_report, create_report, invalidate_report, @@ -94,7 +94,7 @@ submit_report, validate_report, ) -from vultron.as_vocab.examples.status import ( # noqa: F401 +from vultron.wire.as2.vocab.examples.status import ( # noqa: F401 add_status_to_case, add_status_to_participant, case_status, diff --git a/vultron/as_vocab/objects/__init__.py b/vultron/wire/as2/vocab/objects/__init__.py similarity index 100% rename from vultron/as_vocab/objects/__init__.py rename to vultron/wire/as2/vocab/objects/__init__.py diff --git a/vultron/as_vocab/objects/base.py b/vultron/wire/as2/vocab/objects/base.py similarity index 90% rename from vultron/as_vocab/objects/base.py rename to vultron/wire/as2/vocab/objects/base.py index a1245623..f4070d49 100644 --- a/vultron/as_vocab/objects/base.py +++ b/vultron/wire/as2/vocab/objects/base.py @@ -18,8 +18,8 @@ from typing import TypeAlias -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.base import as_Object class VultronObject(as_Object): diff --git a/vultron/as_vocab/objects/case_actor.py b/vultron/wire/as2/vocab/objects/case_actor.py similarity index 96% rename from vultron/as_vocab/objects/case_actor.py rename to vultron/wire/as2/vocab/objects/case_actor.py index 7814e7fa..6c852458 100644 --- a/vultron/as_vocab/objects/case_actor.py +++ b/vultron/wire/as2/vocab/objects/case_actor.py @@ -16,7 +16,7 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -from vultron.as_vocab.base.objects.actors import as_Service +from vultron.wire.as2.vocab.base.objects.actors import as_Service class CaseActor(as_Service): diff --git a/vultron/as_vocab/objects/case_event.py b/vultron/wire/as2/vocab/objects/case_event.py similarity index 83% rename from vultron/as_vocab/objects/case_event.py rename to vultron/wire/as2/vocab/objects/case_event.py index aefcba8b..1976d159 100644 --- a/vultron/as_vocab/objects/case_event.py +++ b/vultron/wire/as2/vocab/objects/case_event.py @@ -20,7 +20,8 @@ from pydantic import BaseModel, Field, field_serializer, field_validator -from vultron.as_vocab.base.dt_utils import now_utc +from vultron.wire.as2.vocab.base.dt_utils import now_utc +from vultron.wire.as2.vocab.base.types import NonEmptyString def _now_utc() -> datetime: @@ -46,11 +47,11 @@ class CaseEvent(BaseModel): plan/IMPLEMENTATION_PLAN.md SC-PRE-1. """ - object_id: str = Field( + object_id: NonEmptyString = Field( ..., description="Full URI of the object being acted upon", ) - event_type: str = Field( + event_type: NonEmptyString = Field( ..., description="Short descriptor of the event kind", ) @@ -59,20 +60,6 @@ class CaseEvent(BaseModel): description="Server-generated TZ-aware UTC timestamp set at receipt", ) - @field_validator("object_id") - @classmethod - def validate_object_id_not_empty(cls, v: str) -> str: - if not v.strip(): - raise ValueError("object_id must be a non-empty string") - return v - - @field_validator("event_type") - @classmethod - def validate_event_type_not_empty(cls, v: str) -> str: - if not v.strip(): - raise ValueError("event_type must be a non-empty string") - return v - @field_validator("received_at", mode="before") @classmethod def parse_received_at(cls, v) -> datetime: diff --git a/vultron/as_vocab/objects/case_participant.py b/vultron/wire/as2/vocab/objects/case_participant.py similarity index 93% rename from vultron/as_vocab/objects/case_participant.py rename to vultron/wire/as2/vocab/objects/case_participant.py index eab21c5c..02387f66 100644 --- a/vultron/as_vocab/objects/case_participant.py +++ b/vultron/wire/as2/vocab/objects/case_participant.py @@ -22,13 +22,14 @@ from pydantic import Field, field_validator, field_serializer, model_validator -from vultron.as_vocab.base.links import as_Link, ActivityStreamRef -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.objects.base import VultronObject -from vultron.as_vocab.objects.case_status import ParticipantStatus +from vultron.wire.as2.vocab.base.links import as_Link, ActivityStreamRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.types import OptionalNonEmptyString +from vultron.wire.as2.vocab.objects.base import VultronObject +from vultron.wire.as2.vocab.objects.case_status import ParticipantStatus from vultron.bt.report_management.states import RM from vultron.bt.roles.states import CVDRoles as CVDRole -from vultron.enums import VultronObjectType as VO_type +from vultron.core.models.enums import VultronObjectType as VO_type @activitystreams_object @@ -63,11 +64,13 @@ class CaseParticipant(VultronObject): as_type: VO_type = Field(default=VO_type.CASE_PARTICIPANT, alias="type") - name: str | None = None + name: OptionalNonEmptyString = None case_roles: list[CVDRole] = Field(default_factory=list) participant_statuses: list[ParticipantStatus] = Field(default_factory=list) accepted_embargo_ids: list[str] = Field(default_factory=list) - participant_case_name: str | None = Field(default=None, exclude=True) + participant_case_name: OptionalNonEmptyString = Field( + default=None, exclude=True + ) context: as_Link | str | None = Field(default=None, repr=True) @field_serializer("case_roles") @@ -271,7 +274,7 @@ def set_role(self): def main(): - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.base.objects.actors import as_Actor actor = as_Actor(name="Actor Name") cp = CaseParticipant(attributed_to=actor, context="case_id_foo") diff --git a/vultron/as_vocab/objects/case_reference.py b/vultron/wire/as2/vocab/objects/case_reference.py similarity index 82% rename from vultron/as_vocab/objects/case_reference.py rename to vultron/wire/as2/vocab/objects/case_reference.py index a01acffe..c3ffdda2 100644 --- a/vultron/as_vocab/objects/case_reference.py +++ b/vultron/wire/as2/vocab/objects/case_reference.py @@ -20,10 +20,14 @@ from pydantic import Field, field_validator -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.objects.base import VultronObject -from vultron.enums import VultronObjectType as VO_type +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.types import ( + NonEmptyString, + OptionalNonEmptyString, +) +from vultron.wire.as2.vocab.objects.base import VultronObject +from vultron.core.models.enums import VultronObjectType as VO_type # CVE JSON Schema reference tag vocabulary CASE_REFERENCE_TAG_VOCABULARY = { @@ -68,11 +72,11 @@ class CaseReference(VultronObject): as_type: VO_type = Field(default=VO_type.CASE_REFERENCE, alias="type") - url: str = Field( + url: NonEmptyString = Field( ..., description="URL reference for the external resource", ) - name: str | None = Field( + name: OptionalNonEmptyString = Field( default=None, description="Human-readable title for the reference", ) @@ -81,20 +85,6 @@ class CaseReference(VultronObject): description="Type descriptors from CVE JSON schema vocabulary", ) - @field_validator("url") - @classmethod - def validate_url_not_empty(cls, v): - if not isinstance(v, str) or not v.strip(): - raise ValueError("url must be a non-empty string") - return v - - @field_validator("name") - @classmethod - def validate_name_not_empty(cls, v): - if v is not None and (not isinstance(v, str) or not v.strip()): - raise ValueError("name must be either None or a non-empty string") - return v - @field_validator("tags") @classmethod def validate_tags(cls, v): @@ -119,7 +109,7 @@ def validate_tags(cls, v): def main(): - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.base.objects.actors import as_Actor actor = as_Actor() obj = CaseReference( diff --git a/vultron/as_vocab/objects/case_status.py b/vultron/wire/as2/vocab/objects/case_status.py similarity index 89% rename from vultron/as_vocab/objects/case_status.py rename to vultron/wire/as2/vocab/objects/case_status.py index 566d8b23..6ca1d9b3 100644 --- a/vultron/as_vocab/objects/case_status.py +++ b/vultron/wire/as2/vocab/objects/case_status.py @@ -20,14 +20,15 @@ from pydantic import Field, field_serializer, field_validator, model_validator -from vultron.as_vocab.base.links import as_Link, ActivityStreamRef -from vultron.as_vocab.base.objects.base import as_Object -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.objects.base import VultronObject +from vultron.wire.as2.vocab.base.links import as_Link, ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.types import OptionalNonEmptyString +from vultron.wire.as2.vocab.objects.base import VultronObject from vultron.bt.embargo_management.states import EM from vultron.bt.report_management.states import RM from vultron.case_states.states import CS_pxa, CS_vfd -from vultron.enums import VultronObjectType as VO_type +from vultron.core.models.enums import VultronObjectType as VO_type @activitystreams_object @@ -38,7 +39,7 @@ class CaseStatus(VultronObject): as_type: VO_type = Field(default=VO_type.CASE_STATUS, alias="type") - context: str | None = None # Case ID goes here + context: OptionalNonEmptyString = None # Case ID goes here em_state: EM = EM.NO_EMBARGO pxa_state: CS_pxa = CS_pxa.pxa @@ -88,7 +89,7 @@ class ParticipantStatus(VultronObject): vfd_state: CS_vfd = CS_vfd.vfd case_engagement: bool = True embargo_adherence: bool = True - tracking_id: str | None = None + tracking_id: OptionalNonEmptyString = None case_status: CaseStatus | None = None @field_serializer("rm_state") diff --git a/vultron/as_vocab/objects/embargo_event.py b/vultron/wire/as2/vocab/objects/embargo_event.py similarity index 89% rename from vultron/as_vocab/objects/embargo_event.py rename to vultron/wire/as2/vocab/objects/embargo_event.py index b3506ff2..beee824f 100644 --- a/vultron/as_vocab/objects/embargo_event.py +++ b/vultron/wire/as2/vocab/objects/embargo_event.py @@ -22,13 +22,13 @@ from pydantic import Field, model_validator -from vultron.as_vocab.base.dt_utils import ( +from vultron.wire.as2.vocab.base.dt_utils import ( now_utc, ) -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.object_types import as_Event -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.base.utils import name_of +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.object_types import as_Event +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.utils import name_of def _45_days_hence(): diff --git a/vultron/as_vocab/objects/embargo_policy.py b/vultron/wire/as2/vocab/objects/embargo_policy.py similarity index 90% rename from vultron/as_vocab/objects/embargo_policy.py rename to vultron/wire/as2/vocab/objects/embargo_policy.py index 8b2152dc..05089393 100644 --- a/vultron/as_vocab/objects/embargo_policy.py +++ b/vultron/wire/as2/vocab/objects/embargo_policy.py @@ -20,11 +20,14 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.base.types import NonEmptyString, OptionalNonEmptyString -from vultron.as_vocab.objects.base import VultronObject -from vultron.enums import VultronObjectType as VO_type +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.types import ( + NonEmptyString, + OptionalNonEmptyString, +) +from vultron.wire.as2.vocab.objects.base import VultronObject +from vultron.core.models.enums import VultronObjectType as VO_type @activitystreams_object diff --git a/vultron/as_vocab/objects/vulnerability_case.py b/vultron/wire/as2/vocab/objects/vulnerability_case.py similarity index 69% rename from vultron/as_vocab/objects/vulnerability_case.py rename to vultron/wire/as2/vocab/objects/vulnerability_case.py index 02641a00..5d71f4fd 100644 --- a/vultron/as_vocab/objects/vulnerability_case.py +++ b/vultron/wire/as2/vocab/objects/vulnerability_case.py @@ -20,20 +20,26 @@ from pydantic import Field, model_validator -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.object_types import as_NoteRef -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.objects.base import VultronObject -from vultron.as_vocab.objects.case_event import CaseEvent -from vultron.as_vocab.objects.case_participant import CaseParticipantRef -from vultron.as_vocab.objects.case_status import CaseStatus, CaseStatusRef -from vultron.as_vocab.objects.embargo_event import EmbargoEventRef -from vultron.as_vocab.objects.vulnerability_report import ( +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.object_types import as_NoteRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.objects.base import VultronObject +from vultron.wire.as2.vocab.objects.case_event import CaseEvent +from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + CaseParticipantRef, +) +from vultron.wire.as2.vocab.objects.case_status import ( + CaseStatus, + CaseStatusRef, +) +from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEventRef +from vultron.wire.as2.vocab.objects.vulnerability_report import ( VulnerabilityReportRef, ) from vultron.bt.embargo_management.states import EM -from vultron.enums import VultronObjectType as VO_type +from vultron.core.models.enums import VultronObjectType as VO_type def init_case_status(): @@ -52,6 +58,7 @@ class VulnerabilityCase(VultronObject): as_type: VO_type = Field(default=VO_type.VULNERABILITY_CASE, alias="type") case_participants: list[CaseParticipantRef] = Field(default_factory=list) + actor_participant_index: dict[str, str] = Field(default_factory=dict) vulnerability_reports: list[VulnerabilityReportRef] = Field( default_factory=list ) @@ -89,14 +96,48 @@ def add_report(self, report: VulnerabilityReportRef) -> None: """ self.vulnerability_reports.append(report) - def add_participant(self, participant: CaseParticipantRef) -> None: - """Add a participant to the case + def add_participant(self, participant: CaseParticipant) -> None: + """Add a participant to the case, maintaining the actor_participant_index. + + The participant's actor ID (from ``attributed_to``) is recorded in + ``actor_participant_index`` so callers can quickly look up a + participant by actor ID. Args: - participant: a CaseParticipant object + participant: a CaseParticipant object (full object required to + update the index) """ self.case_participants.append(participant) + attributed_to = participant.attributed_to + if attributed_to is not None: + actor_id = ( + attributed_to.as_id + if hasattr(attributed_to, "as_id") + else str(attributed_to) + ) + self.actor_participant_index[actor_id] = participant.as_id + + def remove_participant(self, participant_id: str) -> None: + """Remove a participant from the case, maintaining the actor_participant_index. + + Args: + participant_id: the ID of the CaseParticipant to remove + """ + self.case_participants = [ + p + for p in self.case_participants + if (p.as_id if hasattr(p, "as_id") else p) != participant_id + ] + + actors_to_remove = [ + actor_id + for actor_id, p_id in self.actor_participant_index.items() + if p_id == participant_id + ] + for actor_id in actors_to_remove: + del self.actor_participant_index[actor_id] + @property def current_status(self) -> CaseStatus: """Return the most recent CaseStatus, sorted by updated timestamp.""" diff --git a/vultron/as_vocab/objects/vulnerability_record.py b/vultron/wire/as2/vocab/objects/vulnerability_record.py similarity index 85% rename from vultron/as_vocab/objects/vulnerability_record.py rename to vultron/wire/as2/vocab/objects/vulnerability_record.py index 5c0233b9..82d34de2 100644 --- a/vultron/as_vocab/objects/vulnerability_record.py +++ b/vultron/wire/as2/vocab/objects/vulnerability_record.py @@ -20,11 +20,14 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.base.types import NonEmptyString -from vultron.as_vocab.objects.base import VultronObject -from vultron.enums import VultronObjectType as VO_type +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.base.types import ( + NonEmptyString, + OptionalNonEmptyString, +) +from vultron.wire.as2.vocab.objects.base import VultronObject +from vultron.core.models.enums import VultronObjectType as VO_type @activitystreams_object @@ -56,7 +59,7 @@ class VulnerabilityRecord(VultronObject): default_factory=list, description="Alternative identifiers from different namespaces", ) - url: str | None = Field( + url: OptionalNonEmptyString = Field( default=None, description="Optional URL reference for the vulnerability record", ) @@ -66,7 +69,7 @@ class VulnerabilityRecord(VultronObject): def main(): - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.base.objects.actors import as_Actor actor = as_Actor() obj = VulnerabilityRecord( diff --git a/vultron/as_vocab/objects/vulnerability_report.py b/vultron/wire/as2/vocab/objects/vulnerability_report.py similarity index 84% rename from vultron/as_vocab/objects/vulnerability_report.py rename to vultron/wire/as2/vocab/objects/vulnerability_report.py index f18bdf51..fd026276 100644 --- a/vultron/as_vocab/objects/vulnerability_report.py +++ b/vultron/wire/as2/vocab/objects/vulnerability_report.py @@ -20,10 +20,10 @@ from pydantic import Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.objects.base import VultronObject -from vultron.enums import VultronObjectType as VO_type +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.objects.base import VultronObject +from vultron.core.models.enums import VultronObjectType as VO_type @activitystreams_object @@ -41,7 +41,7 @@ class VulnerabilityReport(VultronObject): def main(): - from vultron.as_vocab.base.objects.actors import as_Actor + from vultron.wire.as2.vocab.base.objects.actors import as_Actor reporter = as_Actor() obj = VulnerabilityReport( diff --git a/vultron/as_vocab/objects/vultron_actor.py b/vultron/wire/as2/vocab/objects/vultron_actor.py similarity index 92% rename from vultron/as_vocab/objects/vultron_actor.py rename to vultron/wire/as2/vocab/objects/vultron_actor.py index a564d48f..b2ce1e06 100644 --- a/vultron/as_vocab/objects/vultron_actor.py +++ b/vultron/wire/as2/vocab/objects/vultron_actor.py @@ -37,14 +37,14 @@ from pydantic import BaseModel, Field -from vultron.as_vocab.base.links import ActivityStreamRef -from vultron.as_vocab.base.objects.actors import ( +from vultron.wire.as2.vocab.base.links import ActivityStreamRef +from vultron.wire.as2.vocab.base.objects.actors import ( as_Organization, as_Person, as_Service, ) -from vultron.as_vocab.base.registry import activitystreams_object -from vultron.as_vocab.objects.embargo_policy import EmbargoPolicyRef +from vultron.wire.as2.vocab.base.registry import activitystreams_object +from vultron.wire.as2.vocab.objects.embargo_policy import EmbargoPolicyRef class VultronActorMixin(BaseModel): diff --git a/vultron/as_vocab/type_helpers.py b/vultron/wire/as2/vocab/type_helpers.py similarity index 72% rename from vultron/as_vocab/type_helpers.py rename to vultron/wire/as2/vocab/type_helpers.py index de68eecf..afe0c556 100644 --- a/vultron/as_vocab/type_helpers.py +++ b/vultron/wire/as2/vocab/type_helpers.py @@ -7,8 +7,8 @@ from typing import TypeVar -from vultron.as_vocab.base.objects.activities.base import as_Activity -from vultron.as_vocab.base.objects.base import as_Object +from vultron.wire.as2.vocab.base.objects.activities.base import as_Activity +from vultron.wire.as2.vocab.base.objects.base import as_Object AsActivityType = TypeVar("AsActivityType", bound=as_Activity) AsObjectType = TypeVar("AsObjectType", bound=as_Object) diff --git a/vultron/wire/errors.py b/vultron/wire/errors.py new file mode 100644 index 00000000..f1e2389e --- /dev/null +++ b/vultron/wire/errors.py @@ -0,0 +1,7 @@ +"""Wire layer errors for the Vultron Protocol.""" + +from vultron.errors import VultronError + + +class VultronWireError(VultronError): + """Base class for wire layer errors."""