---
phase: 15-event-model-hardening
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/config/models.py
  - src/ztlctl/config/settings.py
  - src/ztlctl/domain/events.py
  - src/ztlctl/plugins/event_bus.py
  - tests/config/test_models.py
  - tests/plugins/test_event_bus.py
autonomous: true
requirements: [ARCH-04, DEBT-02]

must_haves:
  truths:
    - "EventBusConfig has shutdown_timeout_seconds, max_retries, dead_letter_retention_days, per_future_timeout_seconds fields with correct defaults"
    - "ZtlSettings exposes eventbus field with EventBusConfig type"
    - "ActionEvent model validates action_name, side_effect, payload, warnings, result fields"
    - "EventBus constructor accepts EventBusConfig and uses configurable timeout in _wait_futures"
  artifacts:
    - path: "src/ztlctl/config/models.py"
      provides: "EventBusConfig frozen Pydantic model"
      contains: "class EventBusConfig"
    - path: "src/ztlctl/domain/events.py"
      provides: "ActionEvent frozen Pydantic model"
      contains: "class ActionEvent"
    - path: "src/ztlctl/config/settings.py"
      provides: "eventbus field on ZtlSettings"
      contains: "eventbus: EventBusConfig"
    - path: "src/ztlctl/plugins/event_bus.py"
      provides: "Configurable EventBus constructor"
      contains: "self._per_future_timeout"
  key_links:
    - from: "src/ztlctl/config/settings.py"
      to: "src/ztlctl/config/models.py"
      via: "import EventBusConfig"
      pattern: "from ztlctl.config.models import.*EventBusConfig"
    - from: "src/ztlctl/plugins/event_bus.py"
      to: "src/ztlctl/config/models.py"
      via: "EventBusConfig parameter"
      pattern: "EventBusConfig"
---

<objective>
Define the foundational types (EventBusConfig, ActionEvent) and refactor EventBus to accept configurable settings instead of hardcoded values.

Purpose: All subsequent plans depend on these types and the configurable EventBus constructor. This plan establishes the contracts that Plan 02 (emission/drain) and Plan 03 (dead-letter) build on.

Output: EventBusConfig in config/models.py, ActionEvent in domain/events.py, EventBus constructor accepting config, ZtlSettings.eventbus field, passing tests.
</objective>

<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/15-event-model-hardening/15-CONTEXT.md
@.planning/phases/15-event-model-hardening/15-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

From src/ztlctl/config/models.py (existing config pattern):
```python
class CheckConfig(BaseModel):
    """[check] section."""
    model_config = {"frozen": True}
    backup_retention_days: int = 30
    backup_max_count: int = 10
```

From src/ztlctl/config/settings.py (existing section wiring):
```python
check: CheckConfig = Field(default_factory=CheckConfig)
```

From src/ztlctl/plugins/event_bus.py (current constructor):
```python
def __init__(
    self,
    engine: Engine,
    plugin_manager: PluginManager,
    *,
    sync: bool = False,
    max_retries: int = 3,
    max_workers: int = 2,
) -> None:
    # ... hardcoded timeout=30 in _wait_futures line 261
```

From src/ztlctl/infrastructure/vault.py (EventBus construction):
```python
self._event_bus = EventBus(self._engine, pm, sync=sync)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: EventBusConfig and ActionEvent models with tests</name>
  <files>
    src/ztlctl/config/models.py,
    src/ztlctl/config/settings.py,
    src/ztlctl/domain/events.py,
    tests/config/test_models.py,
    tests/domain/test_events.py
  </files>
  <read_first>
    src/ztlctl/config/models.py,
    src/ztlctl/config/settings.py,
    tests/config/test_models.py
  </read_first>
  <behavior>
    - Test: EventBusConfig() defaults are shutdown_timeout_seconds=5.0, max_retries=3, dead_letter_retention_days=30, per_future_timeout_seconds=30.0
    - Test: EventBusConfig(shutdown_timeout_seconds=10.0) overrides just that field
    - Test: EventBusConfig is frozen (assignment raises)
    - Test: ZtlSettings().eventbus returns an EventBusConfig with defaults
    - Test: ZtlSettings.eventbus is populated from TOML [eventbus] section
    - Test: ActionEvent(action_name="create_note", side_effect="write", payload={"id": "x", "type": "note"}) validates
    - Test: ActionEvent with side_effect="invalid" raises ValidationError
    - Test: ActionEvent.warnings defaults to empty list
    - Test: ActionEvent.result defaults to None
    - Test: ActionEvent is frozen
    - Test: ActionEvent.model_dump_json() produces valid JSON string
  </behavior>
  <action>
    1. Create `src/ztlctl/domain/events.py` with:
       ```python
       class ActionEvent(BaseModel):
           model_config = {"frozen": True}
           action_name: str
           side_effect: Literal["write", "read"] = "write"
           payload: dict[str, Any]
           warnings: list[str] = []
           result: Any = None
       ```
       Per D-12, D-13, D-14. The `payload` dict contains committed state (id, type, title, path, etc.). The `result` field carries the full ServiceResult.

    2. Add `EventBusConfig` to `src/ztlctl/config/models.py` following the exact frozen BaseModel pattern used by CheckConfig, SessionConfig, etc.:
       ```python
       class EventBusConfig(BaseModel):
           """[eventbus] section."""
           model_config = {"frozen": True}
           shutdown_timeout_seconds: float = 5.0
           max_retries: int = 3
           dead_letter_retention_days: int = 30
           per_future_timeout_seconds: float = 30.0
       ```
       Per D-16.

    3. Add `eventbus: EventBusConfig = Field(default_factory=EventBusConfig)` to `ZtlSettings` in `src/ztlctl/config/settings.py`, alongside the existing section fields. Also add `EventBusConfig` to the import from `ztlctl.config.models`.

    4. Write tests in `tests/config/test_models.py` (append to existing file) for EventBusConfig defaults, override, and frozen behavior.

    5. Create `tests/domain/test_events.py` for ActionEvent validation, defaults, frozen, side_effect literal constraint, and model_dump_json serialization.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/config/test_models.py -k eventbus -x -q --timeout=30 && uv run pytest tests/domain/test_events.py -x -q --timeout=30</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/config/models.py contains `class EventBusConfig(BaseModel):`
    - src/ztlctl/config/models.py contains `shutdown_timeout_seconds: float = 5.0`
    - src/ztlctl/config/models.py contains `per_future_timeout_seconds: float = 30.0`
    - src/ztlctl/config/settings.py contains `eventbus: EventBusConfig`
    - src/ztlctl/config/settings.py contains `EventBusConfig` in imports from ztlctl.config.models
    - src/ztlctl/domain/events.py contains `class ActionEvent(BaseModel):`
    - src/ztlctl/domain/events.py contains `action_name: str`
    - src/ztlctl/domain/events.py contains `side_effect: Literal["write", "read"]`
    - src/ztlctl/domain/events.py contains `result: Any = None`
    - tests/domain/test_events.py exits 0
    - tests/config/test_models.py -k eventbus exits 0
  </acceptance_criteria>
  <done>EventBusConfig and ActionEvent models exist with full test coverage. ZtlSettings.eventbus field wired.</done>
</task>

<task type="auto">
  <name>Task 2: Refactor EventBus constructor to accept EventBusConfig</name>
  <files>
    src/ztlctl/plugins/event_bus.py,
    src/ztlctl/infrastructure/vault.py,
    tests/plugins/test_event_bus.py
  </files>
  <read_first>
    src/ztlctl/plugins/event_bus.py,
    src/ztlctl/infrastructure/vault.py,
    tests/plugins/test_event_bus.py
  </read_first>
  <action>
    1. Modify `EventBus.__init__()` in `src/ztlctl/plugins/event_bus.py` to accept an optional `config: EventBusConfig | None = None` parameter (per D-17). When config is provided, use its values; when None, use current defaults for backward compatibility with tests:
       ```python
       def __init__(
           self,
           engine: Engine,
           plugin_manager: PluginManager,
           *,
           sync: bool = False,
           config: EventBusConfig | None = None,
           max_retries: int = 3,
           max_workers: int = 2,
       ) -> None:
           self._engine = engine
           self._pm = plugin_manager
           self._sync = sync
           if config is not None:
               self._max_retries = config.max_retries
               self._per_future_timeout = config.per_future_timeout_seconds
               self._shutdown_timeout = config.shutdown_timeout_seconds
               self._dead_letter_retention_days = config.dead_letter_retention_days
           else:
               self._max_retries = max_retries
               self._per_future_timeout = 30.0
               self._shutdown_timeout = 5.0
               self._dead_letter_retention_days = 30
           self._executor = None if sync else ThreadPoolExecutor(max_workers=max_workers)
           self._futures: list[tuple[int, Future[None]]] = []
       ```

    2. Update `_wait_futures()` to use `self._per_future_timeout` instead of hardcoded `30` (per D-18):
       ```python
       future.result(timeout=self._per_future_timeout)
       ```

    3. Update `_mark_failed()` to use `self._max_retries` instead of the constructor parameter (already stored, just confirm consistent usage).

    4. Update `shutdown()` to accept an optional `timeout` parameter and pass it to `_wait_futures()`:
       ```python
       def shutdown(self, *, wait: bool = True, cancel_futures: bool = False, timeout: float | None = None) -> None:
           if wait:
               self._wait_futures(timeout=timeout)
           # ... rest unchanged
       ```
       Also update `_wait_futures` signature to accept a `timeout` override:
       ```python
       def _wait_futures(self, *, event_ids: set[int] | None = None, timeout: float | None = None) -> None:
           per_future_timeout = timeout if timeout is not None else self._per_future_timeout
           # ... use per_future_timeout
       ```

    5. Update `Vault.init_event_bus()` in `src/ztlctl/infrastructure/vault.py` to pass the config:
       ```python
       self._event_bus = EventBus(self._engine, pm, sync=sync, config=self._settings.eventbus)
       ```

    6. Update `Vault.close()` to pass timeout from config:
       ```python
       def close(self, *, wait_for_events: bool = True, timeout: float | None = None) -> None:
           if self._event_bus is not None:
               try:
                   self._event_bus.shutdown(
                       wait=wait_for_events,
                       cancel_futures=not wait_for_events,
                       timeout=timeout,
                   )
               except Exception:
                   logger.debug("Event bus shutdown failed", exc_info=True)
               finally:
                   self._event_bus = None
                   self._plugin_manager = None
           self._graph.invalidate()
           self._engine.dispose()
       ```

    7. Update existing tests in `tests/plugins/test_event_bus.py` if any break due to constructor changes. Add a test that EventBus with config uses configurable timeout (mock Future.result to verify timeout value passed).
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/plugins/test_event_bus.py -x -q --timeout=30</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/plugins/event_bus.py contains `config: EventBusConfig | None = None`
    - src/ztlctl/plugins/event_bus.py contains `self._per_future_timeout`
    - src/ztlctl/plugins/event_bus.py contains `self._shutdown_timeout`
    - src/ztlctl/plugins/event_bus.py contains `self._dead_letter_retention_days`
    - src/ztlctl/plugins/event_bus.py does NOT contain `timeout=30)` as a hardcoded value in _wait_futures
    - src/ztlctl/infrastructure/vault.py contains `config=self._settings.eventbus`
    - src/ztlctl/infrastructure/vault.py close() signature contains `timeout: float | None = None`
    - tests/plugins/test_event_bus.py exits 0
    - Full test suite passes: `uv run pytest tests/ -x -q --timeout=30` exits 0
  </acceptance_criteria>
  <done>EventBus uses configurable timeouts from EventBusConfig. No hardcoded timeout values remain. Vault wires config through. All existing tests pass.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/config/test_models.py tests/domain/test_events.py tests/plugins/test_event_bus.py -x -q --timeout=30` — all new and existing tests pass
- `uv run ruff check src/ztlctl/config/models.py src/ztlctl/config/settings.py src/ztlctl/domain/events.py src/ztlctl/plugins/event_bus.py` — no lint errors
- `uv run mypy src/ztlctl/config/models.py src/ztlctl/config/settings.py src/ztlctl/domain/events.py src/ztlctl/plugins/event_bus.py` — no type errors
- `uv run pytest tests/ -x -q --timeout=60` — full suite green (no regressions)
</verification>

<success_criteria>
- EventBusConfig model exists with 4 configurable fields and correct defaults
- ActionEvent model exists with frozen validation and Literal["write","read"] constraint
- ZtlSettings.eventbus is populated from TOML [eventbus] section
- EventBus constructor accepts EventBusConfig and stores all timeout/retry values
- _wait_futures uses configurable per-future timeout (no hardcoded 30)
- Vault passes config to EventBus at construction and timeout to shutdown
- All tests pass including full suite regression check
</success_criteria>

<output>
After completion, create `.planning/phases/15-event-model-hardening/15-01-SUMMARY.md`
</output>
