---
phase: 15-event-model-hardening
plan: 02
type: execute
wave: 2
depends_on: ["15-01"]
files_modified:
  - src/ztlctl/services/base.py
  - src/ztlctl/plugins/event_bus.py
  - src/ztlctl/commands/_context.py
  - src/ztlctl/infrastructure/vault.py
  - src/ztlctl/controllers/base.py
  - src/ztlctl/controllers/create.py
  - src/ztlctl/controllers/update.py
  - src/ztlctl/controllers/session.py
  - src/ztlctl/controllers/check.py
  - src/ztlctl/controllers/reweave.py
  - src/ztlctl/controllers/graph.py
  - src/ztlctl/controllers/query.py
  - src/ztlctl/controllers/export.py
  - src/ztlctl/controllers/discovery.py
  - src/ztlctl/controllers/workflow.py
  - src/ztlctl/controllers/upgrade.py
  - src/ztlctl/controllers/vector.py
  - src/ztlctl/controllers/ingest.py
  - src/ztlctl/controllers/init_ctrl.py
  - tests/plugins/test_event_bus.py
  - tests/controllers/test_post_action_removal.py
autonomous: true
requirements: [ARCH-01, ARCH-02, ARCH-03]

must_haves:
  truths:
    - "After a CLI write command with a slow plugin, no pending WAL rows remain when the process exits (within timeout)"
    - "On startup after an interrupted run, pending WAL events from the prior session drain before new work begins"
    - "Every mutating action has exactly one post_action producer — the service layer — with no controller-side write dispatch"
    - "All post_action events carry ActionEvent payload shape through the WAL"
  artifacts:
    - path: "src/ztlctl/services/base.py"
      provides: "_dispatch_post_action_event method for service-side emission"
      contains: "_dispatch_post_action_event"
    - path: "src/ztlctl/commands/_context.py"
      provides: "Bounded shutdown drain in close()"
      contains: "wait_for_events=True"
    - path: "src/ztlctl/infrastructure/vault.py"
      provides: "Startup drain in init_event_bus()"
      contains: "drain"
    - path: "src/ztlctl/controllers/base.py"
      provides: "Removed _dispatch_post_action for write actions"
  key_links:
    - from: "src/ztlctl/services/base.py"
      to: "src/ztlctl/plugins/event_bus.py"
      via: "_dispatch_post_action_event calls EventBus.dispatch"
      pattern: "bus\\.dispatch.*post_action"
    - from: "src/ztlctl/commands/_context.py"
      to: "src/ztlctl/infrastructure/vault.py"
      via: "close() passes timeout to vault.close()"
      pattern: "vault\\.close.*wait_for_events.*True"
    - from: "src/ztlctl/infrastructure/vault.py"
      to: "src/ztlctl/plugins/event_bus.py"
      via: "init_event_bus calls drain for startup recovery"
      pattern: "event_bus\\.drain"
---

<objective>
Implement the three core behavioral changes: (1) service-only post_action emission with ActionEvent payloads, (2) bounded shutdown drain, (3) startup recovery drain. Remove all controller-side _dispatch_post_action calls for write actions.

Purpose: This is the central correctness fix — moving from two post_action producers (controller + bridge) to one (service), and ensuring WAL rows drain reliably at shutdown and startup.

Output: Service-side emission working, 64 controller call sites cleaned up, shutdown drain with bounded timeout, startup recovery drain, all tests passing.
</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
@.planning/phases/15-event-model-hardening/15-01-SUMMARY.md

<interfaces>
<!-- From Plan 01 outputs -->

From src/ztlctl/domain/events.py (created in Plan 01):
```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
```

From src/ztlctl/config/models.py (created in Plan 01):
```python
class EventBusConfig(BaseModel):
    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
```

From src/ztlctl/plugins/event_bus.py (refactored in Plan 01):
```python
# Constructor accepts config: EventBusConfig | None = None
# self._shutdown_timeout, self._per_future_timeout stored
# shutdown() accepts timeout parameter
# _wait_futures() accepts timeout override
```

From src/ztlctl/services/base.py (current):
```python
class BaseService:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault

    def _dispatch_event(self, hook_name, payload, warnings, *, session_id=None) -> int | None:
        bus = self._vault.event_bus
        if bus is None: return None
        return cast(int, bus.dispatch(hook_name, payload, session_id=session_id))
```

From src/ztlctl/controllers/base.py (current):
```python
def _dispatch_post_action(self, action_name, kwargs, result) -> None:
    pm = self._vault.plugin_manager
    if pm is None: return
    pm.hook.post_action(action_name=action_name, kwargs=kwargs, result=result)
```

From src/ztlctl/plugins/event_bus.py (bridge in _execute_hook):
```python
# Lines 208-221: Bridge fires post_action after per-event hook
action_name = _HOOK_TO_ACTION.get(hook_name)
if action_name is not None:
    post_action_fn = getattr(self._pm.hook, "post_action", None)
    if post_action_fn is not None:
        post_action_fn(action_name=action_name, kwargs=payload, result=None)
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Service-side post_action emission and shutdown/startup drain</name>
  <files>
    src/ztlctl/services/base.py,
    src/ztlctl/plugins/event_bus.py,
    src/ztlctl/commands/_context.py,
    src/ztlctl/infrastructure/vault.py,
    tests/plugins/test_event_bus.py
  </files>
  <read_first>
    src/ztlctl/services/base.py,
    src/ztlctl/plugins/event_bus.py,
    src/ztlctl/commands/_context.py,
    src/ztlctl/infrastructure/vault.py,
    tests/plugins/test_event_bus.py,
    src/ztlctl/plugins/hookspecs.py
  </read_first>
  <action>
    **Part A — Service-side emission (per D-08, D-14, D-15):**

    1. Add `_dispatch_post_action_event()` method to `BaseService` in `src/ztlctl/services/base.py`:
       ```python
       def _dispatch_post_action_event(
           self,
           action_name: str,
           payload: dict[str, Any],
           warnings: list[str],
           result: Any = None,
           *,
           session_id: str | None = None,
       ) -> int | None:
           """Emit a canonical post_action event via the WAL.

           Called by services after a successful write commit.
           The ActionEvent is serialized and stored in the WAL payload column (D-15).
           """
           from ztlctl.domain.events import ActionEvent

           bus = self._vault.event_bus
           if bus is None:
               return None
           try:
               event = ActionEvent(
                   action_name=action_name,
                   side_effect="write",
                   payload=payload,
                   warnings=warnings,
                   result=result,
               )
               return cast(int, bus.dispatch(
                   "post_action",
                   event.model_dump(),
                   session_id=session_id,
               ))
           except Exception:
               logger.debug("post_action dispatch failed for %s", action_name, exc_info=True)
               warnings.append(f"post_action dispatch failed for {action_name}")
               return None
       ```

    2. Update `EventBus._execute_hook()` to handle `post_action` hook dispatch correctly. When `hook_name == "post_action"`, the payload IS the ActionEvent dict. Call `pm.hook.post_action(action_name=payload["action_name"], kwargs=payload["payload"], result=payload.get("result"))` instead of the generic `hook_fn(**payload)`. This ensures plugins receive the stable interface they expect.

       Specifically, update the `_execute_hook` method to add special handling:
       ```python
       def _execute_hook(self, event_id, hook_name, payload):
           if hook_name == "post_action":
               # Canonical post_action: payload is ActionEvent dict
               post_action_fn = getattr(self._pm.hook, "post_action", None)
               if post_action_fn is None:
                   self._mark_completed(event_id)
                   return
               try:
                   post_action_fn(
                       action_name=payload["action_name"],
                       kwargs=payload["payload"],
                       result=payload.get("result"),
                   )
               except Exception as exc:
                   logger.debug("post_action hook failed: %s", exc)
                   self._mark_failed(event_id, str(exc))
               else:
                   self._mark_completed(event_id)
               return

           # Original per-event hook dispatch (unchanged)
           hook_fn = getattr(self._pm.hook, hook_name, None)
           # ... rest of existing code ...
       ```

       Keep the existing bridge code in the per-event hook path (lines 208-221) for backward compatibility until Phase 16 removes it.

    **Part B — Shutdown drain (per D-01, D-02, D-03):**

    3. Update `AppContext.close()` in `src/ztlctl/commands/_context.py`:
       ```python
       def close(self) -> None:
           """Release held resources after command execution."""
           if self._vault is not None:
               timeout = self.settings.eventbus.shutdown_timeout_seconds
               self._vault.close(wait_for_events=True, timeout=timeout)
       ```
       Per D-03: changes from `wait_for_events=False` to `wait_for_events=True`. Per D-02: after timeout, pending events remain as WAL `pending` rows (NOT cancelled).

    **Part C — Startup recovery drain (per D-05, D-06, D-07):**

    4. Add startup drain at the end of `Vault.init_event_bus()` in `src/ztlctl/infrastructure/vault.py`, after EventBus is constructed:
       ```python
       # Startup recovery: drain pending/failed events from prior runs (D-05)
       try:
           self._event_bus.drain()
       except Exception:
           logger.warning("Startup event drain failed; continuing", exc_info=True)
       ```
       Per D-06: uses the same bounded timeout (via EventBus internals). Per D-07: on failure, log warning and continue.

    **Part D — Tests:**

    5. Add tests to `tests/plugins/test_event_bus.py`:
       - `test_shutdown_drain_completes_pending_events`: Create EventBus, dispatch events, call shutdown(wait=True), verify no `pending` WAL rows remain.
       - `test_shutdown_drain_timeout_leaves_pending`: Dispatch event with a slow mock hook, call shutdown with very short timeout, verify WAL rows remain as `pending`.
       - `test_startup_drain_retries_pending_from_prior_run`: Insert `pending` WAL rows directly, create EventBus, call drain(), verify they are processed.
       - `test_post_action_canonical_dispatch`: Dispatch a `post_action` event with ActionEvent dict payload, verify the hook is called with correct action_name, kwargs, result.
  </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/services/base.py contains `def _dispatch_post_action_event(`
    - src/ztlctl/services/base.py contains `from ztlctl.domain.events import ActionEvent`
    - src/ztlctl/services/base.py contains `bus.dispatch("post_action"`
    - src/ztlctl/plugins/event_bus.py _execute_hook contains `if hook_name == "post_action":`
    - src/ztlctl/commands/_context.py contains `wait_for_events=True`
    - src/ztlctl/commands/_context.py contains `self.settings.eventbus.shutdown_timeout_seconds`
    - src/ztlctl/commands/_context.py does NOT contain `wait_for_events=False`
    - src/ztlctl/infrastructure/vault.py init_event_bus contains `self._event_bus.drain()`
    - tests/plugins/test_event_bus.py -k "drain or post_action_canonical" exits 0
  </acceptance_criteria>
  <done>Service-side emission dispatches ActionEvent through WAL. Shutdown drain waits with bounded timeout. Startup drain retries pending events from prior runs. All tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Remove controller-side _dispatch_post_action for write actions</name>
  <files>
    src/ztlctl/controllers/base.py,
    src/ztlctl/controllers/create.py,
    src/ztlctl/controllers/update.py,
    src/ztlctl/controllers/session.py,
    src/ztlctl/controllers/check.py,
    src/ztlctl/controllers/reweave.py,
    src/ztlctl/controllers/graph.py,
    src/ztlctl/controllers/query.py,
    src/ztlctl/controllers/export.py,
    src/ztlctl/controllers/discovery.py,
    src/ztlctl/controllers/workflow.py,
    src/ztlctl/controllers/upgrade.py,
    src/ztlctl/controllers/vector.py,
    src/ztlctl/controllers/ingest.py,
    src/ztlctl/controllers/init_ctrl.py,
    tests/controllers/test_post_action_removal.py
  </files>
  <read_first>
    src/ztlctl/controllers/base.py,
    src/ztlctl/controllers/create.py,
    src/ztlctl/controllers/update.py,
    src/ztlctl/controllers/session.py,
    src/ztlctl/controllers/check.py,
    src/ztlctl/controllers/reweave.py,
    src/ztlctl/controllers/graph.py,
    src/ztlctl/controllers/query.py,
    src/ztlctl/controllers/export.py,
    src/ztlctl/controllers/discovery.py,
    src/ztlctl/controllers/workflow.py,
    src/ztlctl/controllers/upgrade.py,
    src/ztlctl/controllers/vector.py,
    src/ztlctl/controllers/ingest.py,
    src/ztlctl/controllers/init_ctrl.py
  </read_first>
  <action>
    Per D-09 and D-10: Remove ALL `self._dispatch_post_action()` calls from all 14 controllers (both write AND read actions). Per D-11: single-step cutover, no deprecation window. Both built-in plugins (GitPlugin, ReweavePlugin) already guard for `result=None`.

    **Important context:** D-10 says "no post_action emitted for read actions." D-09 says remove from write actions. Combined, ALL controller `_dispatch_post_action()` calls are removed. Service-side emission (Task 1) handles write actions. Read actions get no post_action at all.

    1. **Remove `_dispatch_post_action` method from `BaseController`** in `src/ztlctl/controllers/base.py` (lines 84-102). Keep `_dispatch_pre_action` and `_dispatch_event` — those are still used.

    2. **Remove all call sites across 14 controllers.** The count from grep is 64 occurrences across 15 files (including base.py definition). For each controller file, find every `self._dispatch_post_action(...)` call and remove the entire statement (usually 1-4 lines including the call with its arguments). The calls are typically at the end of action methods, after the service call returns.

    Controller files and approximate call counts to remove:
    - `create.py`: 4 calls
    - `update.py`: 3 calls
    - `session.py`: 9 calls
    - `check.py`: 4 calls
    - `reweave.py`: 3 calls
    - `graph.py`: 8 calls
    - `query.py`: 10 calls
    - `export.py`: 4 calls
    - `discovery.py`: 3 calls
    - `workflow.py`: 4 calls
    - `upgrade.py`: 3 calls
    - `vector.py`: 2 calls
    - `ingest.py`: 4 calls
    - `init_ctrl.py`: 3 calls

    3. **Create verification test** `tests/controllers/test_post_action_removal.py` that:
       - Uses `ast.parse` to scan all controller files
       - Asserts no file contains a call to `_dispatch_post_action`
       - Asserts `BaseController` does not define `_dispatch_post_action`
       This is a structural test to prevent accidental re-introduction.

    4. Run full test suite to confirm no regressions. The removal should be safe because:
       - Write-action post_action is now handled by service-side emission (Task 1)
       - The EventBus bridge (in _execute_hook) still fires post_action for per-event hooks
       - Read-action post_action was informational only — plugins don't depend on it
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/controllers/test_post_action_removal.py -x -q --timeout=30 && uv run pytest tests/ -x -q --timeout=60</automated>
  </verify>
  <acceptance_criteria>
    - `grep -r "_dispatch_post_action" src/ztlctl/controllers/` returns 0 matches
    - src/ztlctl/controllers/base.py does NOT contain `def _dispatch_post_action`
    - src/ztlctl/controllers/base.py still contains `def _dispatch_pre_action`
    - src/ztlctl/controllers/base.py still contains `def _dispatch_event`
    - tests/controllers/test_post_action_removal.py exits 0
    - Full test suite `uv run pytest tests/ -x -q --timeout=60` exits 0
  </acceptance_criteria>
  <done>All 64 controller-side _dispatch_post_action calls removed. BaseController._dispatch_post_action method deleted. Structural regression test prevents re-introduction. Full suite green.</done>
</task>

</tasks>

<verification>
- `grep -r "_dispatch_post_action" src/ztlctl/controllers/` returns 0 matches (ARCH-03)
- `uv run pytest tests/plugins/test_event_bus.py -k "drain or startup or post_action" -x -q` — drain and emission tests pass (ARCH-01, ARCH-02)
- `uv run pytest tests/ -x -q --timeout=60` — full suite green
- `uv run ruff check src/` — no lint errors
- `uv run mypy src/` — no type errors
</verification>

<success_criteria>
- Services are the sole post_action producer (ARCH-03 satisfied)
- post_action events carry ActionEvent payload through WAL (ARCH-04 dependency satisfied)
- Shutdown drain waits with bounded timeout; pending events remain as WAL rows (ARCH-01 satisfied)
- Startup drain retries pending/failed WAL events before new work begins (ARCH-02 satisfied)
- No controller files contain _dispatch_post_action calls
- All tests pass including full regression suite
</success_criteria>

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