---
phase: 15-event-model-hardening
plan: 04
type: execute
wave: 1
depends_on: ["15-02"]
files_modified:
  - src/ztlctl/services/create.py
  - src/ztlctl/services/update.py
  - src/ztlctl/services/session.py
  - src/ztlctl/services/reweave.py
  - src/ztlctl/services/check.py
  - src/ztlctl/services/graph.py
  - tests/services/test_post_action_dispatch.py
  - .planning/REQUIREMENTS.md
autonomous: true
gap_closure: true
requirements: [ARCH-03, ARCH-04, DEBT-02]

must_haves:
  truths:
    - "Every mutating service method calls _dispatch_post_action_event after successful write commits"
    - "ActionEvent payloads reach the WAL from real service operations"
    - "REQUIREMENTS.md reflects ARCH-04 and DEBT-02 as Complete"
  artifacts:
    - path: "src/ztlctl/services/create.py"
      provides: "post_action dispatch for create_note, create_reference, create_task, create_batch"
      contains: "_dispatch_post_action_event"
    - path: "src/ztlctl/services/update.py"
      provides: "post_action dispatch for update, archive"
      contains: "_dispatch_post_action_event"
    - path: "src/ztlctl/services/session.py"
      provides: "post_action dispatch for session start, close, extract_decision"
      contains: "_dispatch_post_action_event"
    - path: "src/ztlctl/services/reweave.py"
      provides: "post_action dispatch for reweave, prune, undo"
      contains: "_dispatch_post_action_event"
    - path: "src/ztlctl/services/check.py"
      provides: "post_action dispatch for fix, rebuild"
      contains: "_dispatch_post_action_event"
    - path: "src/ztlctl/services/graph.py"
      provides: "post_action dispatch for unlink"
      contains: "_dispatch_post_action_event"
    - path: "tests/services/test_post_action_dispatch.py"
      provides: "Regression test verifying all write services call _dispatch_post_action_event"
    - path: ".planning/REQUIREMENTS.md"
      provides: "ARCH-04 and DEBT-02 marked Complete"
  key_links:
    - from: "src/ztlctl/services/create.py"
      to: "src/ztlctl/services/base.py"
      via: "self._dispatch_post_action_event() inherited call"
      pattern: "self\\._dispatch_post_action_event"
    - from: "src/ztlctl/services/base.py"
      to: "src/ztlctl/plugins/event_bus.py"
      via: "_dispatch_post_action_event calls bus.dispatch('post_action', ...)"
      pattern: "bus\\.dispatch.*post_action"
---

<objective>
Wire `_dispatch_post_action_event` into all write-side service methods. The method already exists on BaseService and is correct, but no service ever calls it. This is the root cause of two verification blockers: zero post_action producers, and no ActionEvent payloads reaching the WAL. Also update REQUIREMENTS.md to reflect completed requirements.

Purpose: Close the gap between "controller post_action removed" and "service post_action added" — completing the migration to service-only emission (ARCH-03).

Output: All write services dispatch canonical ActionEvent payloads through the WAL after commits. REQUIREMENTS.md updated.
</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-02-SUMMARY.md
@.planning/phases/15-event-model-hardening/15-VERIFICATION.md

<interfaces>
<!-- BaseService._dispatch_post_action_event already exists and is correct -->

From src/ztlctl/services/base.py (lines 58-96):
```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."""
    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
```

Signature pattern for all call sites:
```python
self._dispatch_post_action_event(
    action_name="<op_name>",        # matches ServiceResult.op
    payload=result.data,             # committed state dict
    warnings=warnings,               # mutable list
    result=result,                   # full ServiceResult
)
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add _dispatch_post_action_event calls to all write services</name>
  <files>
    src/ztlctl/services/create.py,
    src/ztlctl/services/update.py,
    src/ztlctl/services/session.py,
    src/ztlctl/services/reweave.py,
    src/ztlctl/services/check.py,
    src/ztlctl/services/graph.py,
    tests/services/test_post_action_dispatch.py
  </files>
  <read_first>
    src/ztlctl/services/base.py,
    src/ztlctl/services/create.py,
    src/ztlctl/services/update.py,
    src/ztlctl/services/session.py,
    src/ztlctl/services/reweave.py,
    src/ztlctl/services/check.py,
    src/ztlctl/services/graph.py
  </read_first>
  <action>
    Add `self._dispatch_post_action_event(...)` calls at each successful write commit path. Place the call AFTER the existing `_dispatch_event` call (per-event hook) and BEFORE the final `return ServiceResult(...)`. The call is strictly additive — do NOT modify any existing logic. Use the ServiceResult that is about to be returned as the `result` parameter.

    **CRITICAL PATTERN:** For each method, the call goes right before the success return, after all other post-commit work (events, vector indexing). The `action_name` MUST match the `op` string used in the ServiceResult. The `payload` is `result.data` (the committed state dict). Build the result first, then dispatch, then return.

    Where the method builds the result inline in the return statement, extract it to a local variable first:
    ```python
    # BEFORE (existing pattern):
    return ServiceResult(ok=True, op="create_note", data={...}, warnings=warnings)

    # AFTER (with dispatch):
    result = ServiceResult(ok=True, op="create_note", data={...}, warnings=warnings)
    self._dispatch_post_action_event(
        action_name="create_note",
        payload=result.data,
        warnings=warnings,
        result=result,
    )
    return result
    ```

    **Exact call sites (13 methods across 6 files):**

    ### src/ztlctl/services/create.py

    1. **`_create_content` method (~line 355):** After `self._vector_index_created(created, warnings)` and before `return ServiceResult(...)`. Use `action_name=result.op` (which is `create_note`, `create_reference`, or `create_task` depending on content_type). Extract the ServiceResult to a variable:
       ```python
       result = ServiceResult(ok=True, op=result.op, data=result.data, warnings=warnings)
       self._dispatch_post_action_event(
           action_name=result.op,
           payload=result.data,
           warnings=warnings,
           result=result,
       )
       return result
       ```

    2. **`create_batch` method (~line 295):** After the for-loop dispatching post_create events and before `return ServiceResult(...)`. Use `action_name="create_batch"`:
       ```python
       result = ServiceResult(ok=True, op="create_batch", data={"created": results, "errors": errors}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name="create_batch",
           payload=result.data,
           warnings=warnings,
           result=result,
       )
       return result
       ```

    ### src/ztlctl/services/update.py

    3. **`update` method (~line 238):** After vector re-index block and before the RESPOND return. Use `action_name=op` (which is `"update_note"`, `"update_reference"`, or `"update_task"`):
       ```python
       result = ServiceResult(ok=True, op=op, data={...}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name=op,
           payload=result.data,
           warnings=warnings,
           result=result,
       )
       return result
       ```

    4. **`archive` method (~line 298):** After `_dispatch_event("post_close", ...)` and before the success return. Use `action_name="archive"`:
       ```python
       result = ServiceResult(ok=True, op="archive", data={...}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name="archive",
           payload=result.data,
           warnings=warnings,
           result=result,
       )
       return result
       ```

    ### src/ztlctl/services/session.py

    5. **`start` method (~line 112):** After `_dispatch_event("post_session_start", ...)` and before the success return. Use `action_name="session_start"` (matching the `op` variable):
       ```python
       result = ServiceResult(ok=True, op=op, data={...}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name=op,
           payload=result.data,
           warnings=warnings,
           result=result,
           session_id=session_id,
       )
       return result
       ```

    6. **`close` method (~line 222):** After `bus.drain(event_ids=[close_event_id])` and before the REPORT return. Use `action_name="session_close"`:
       ```python
       result = ServiceResult(ok=True, op=op, data={...}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name=op,
           payload=result.data,
           warnings=warnings,
           result=result,
           session_id=session_id,
       )
       return result
       ```

    7. **`extract_decision` method (~line 604):** After `_dispatch_event("post_extract_decision", ...)` and before the success return. Use `action_name="extract_decision"`:
       ```python
       result = ServiceResult(ok=True, op=op, data={...}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name=op,
           payload=result.data,
           warnings=warnings,
           result=result,
           session_id=session_id,
       )
       return result
       ```

    ### src/ztlctl/services/reweave.py

    8. **`reweave` method (~line 164):** After `_dispatch_event("post_reweave", ...)` and before the success return. Use `action_name="reweave"`:
       ```python
       result = ServiceResult(ok=True, op=op, data={...}, warnings=warnings)
       self._dispatch_post_action_event(
           action_name=op,
           payload=result.data,
           warnings=warnings,
           result=result,
       )
       return result
       ```

    9. **`prune` method (~line 246):** After `self._prune_links(target_id, stale)` and before the non-dry-run success return. Use `action_name="prune"`. Note: do NOT add for the dry_run return (dry_run is read-only):
       ```python
       result = ServiceResult(ok=True, op=op, data={...})
       self._dispatch_post_action_event(
           action_name=op,
           payload=result.data,
           warnings=[],
           result=result,
       )
       return result
       ```

    10. **`undo` method (~line 308):** After `self._apply_undo(entries)` and before the success return. Use `action_name="undo"`:
        ```python
        result = ServiceResult(ok=True, op=op, data={...})
        self._dispatch_post_action_event(
            action_name=op,
            payload=result.data,
            warnings=[],
            result=result,
        )
        return result
        ```

    ### src/ztlctl/services/check.py

    11. **`fix` method (~line 150):** After the transaction block and before the success return. Use `action_name="fix"`:
        ```python
        result = ServiceResult(ok=True, op="fix", data={"fixes": fixes, "count": len(fixes)})
        self._dispatch_post_action_event(
            action_name="fix",
            payload=result.data,
            warnings=[],
            result=result,
        )
        return result
        ```

    12. **`rebuild` method:** At the end, before the success return (which is at the end of the method). Use `action_name="rebuild"`. Extract the result:
        ```python
        result = ServiceResult(ok=True, op="rebuild", data={...}, warnings=warnings)
        self._dispatch_post_action_event(
            action_name="rebuild",
            payload=result.data,
            warnings=warnings,
            result=result,
        )
        return result
        ```

    ### src/ztlctl/services/graph.py

    13. **`unlink` method:** After the transaction commits and before the success return at the end. Use `action_name="unlink"`:
        ```python
        result = ServiceResult(ok=True, op=op, data={...}, warnings=warnings)
        self._dispatch_post_action_event(
            action_name=op,
            payload=result.data,
            warnings=warnings,
            result=result,
        )
        return result
        ```

    **NOTE on `check` method (CheckService.check):** This is a READ operation — it inspects but does not modify data. Per D-10, no post_action for reads. Do NOT add _dispatch_post_action_event to `check()`.

    **NOTE on `session.reopen`:** This is a write but was NOT previously dispatched by controllers. Add it for consistency:
    14. **`reopen` method:** After the transaction and before the success return. Use `action_name="session_reopen"`.

    **Create regression test** `tests/services/test_post_action_dispatch.py`:

    Use `ast.parse` to scan all service files and verify every method that contains a `self._vault.transaction()` call (indicating a write) also calls `self._dispatch_post_action_event`. This is a structural test similar to `test_post_action_removal.py` for controllers.

    ```python
    """Verify all write services call _dispatch_post_action_event."""
    import ast
    from pathlib import Path

    SERVICE_DIR = Path("src/ztlctl/services")

    # Methods that are known write operations but legitimately skip post_action:
    # - _create_content_in_txn: private helper called by _create_content (which dispatches)
    # - _apply_undo: private helper called by undo (which dispatches)
    # - _prune_links: private helper called by prune (which dispatches)
    # - _backup_db: helper, not an action
    # - _fix_*: private helpers called by fix (which dispatches)
    # - check: read operation, not a write
    EXEMPT_METHODS = {
        "_create_content_in_txn", "_apply_undo", "_prune_links",
        "_backup_db", "_fix_orphan_db_rows", "_fix_dangling_edges",
        "_fix_missing_fts", "_fix_resync_from_files", "_fix_reindex_edges",
        "_fix_reorder_frontmatter", "_connect", "_cross_session_reweave",
        "_orphan_sweep", "_integrity_check", "check",
        "_check_structural_validation", "_check_referential_integrity",
        "_check_graph_consistency", "_check_content_quality",
    }

    def test_write_services_dispatch_post_action():
        """Every public write method in services must call _dispatch_post_action_event."""
        service_files = [
            "create.py", "update.py", "session.py",
            "reweave.py", "check.py", "graph.py",
        ]
        missing = []
        for filename in service_files:
            path = SERVICE_DIR / filename
            tree = ast.parse(path.read_text())
            for node in ast.walk(tree):
                if not isinstance(node, ast.ClassDef):
                    continue
                for item in node.body:
                    if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        continue
                    if item.name in EXEMPT_METHODS:
                        continue
                    # Check if method has transaction() call
                    has_transaction = any(
                        isinstance(call, ast.Attribute) and call.attr == "transaction"
                        for call in ast.walk(item)
                        if isinstance(call, ast.Attribute)
                    )
                    if not has_transaction:
                        continue
                    # Check if method calls _dispatch_post_action_event
                    has_dispatch = any(
                        isinstance(call, ast.Attribute)
                        and call.attr == "_dispatch_post_action_event"
                        for call in ast.walk(item)
                        if isinstance(call, ast.Attribute)
                    )
                    if not has_dispatch:
                        missing.append(f"{filename}::{node.name}.{item.name}")
        assert not missing, (
            f"Write methods missing _dispatch_post_action_event: {missing}"
        )
    ```

    Adjust the EXEMPT_METHODS set as needed based on what you find in the actual files — the goal is that every public method with a `self._vault.transaction()` block (indicating a write) either calls `_dispatch_post_action_event` or is in the exempt list with a documented reason.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/services/test_post_action_dispatch.py -x -q --timeout=30 && uv run pytest tests/ -x -q --timeout=120 && uv run ruff check src/ztlctl/services/ && uv run mypy src/ztlctl/services/</automated>
  </verify>
  <acceptance_criteria>
    - `grep -r "_dispatch_post_action_event" src/ztlctl/services/create.py` returns at least 2 matches (one for _create_content, one for create_batch)
    - `grep -r "_dispatch_post_action_event" src/ztlctl/services/update.py` returns at least 2 matches (update, archive)
    - `grep -r "_dispatch_post_action_event" src/ztlctl/services/session.py` returns at least 3 matches (start, close, extract_decision)
    - `grep -r "_dispatch_post_action_event" src/ztlctl/services/reweave.py` returns at least 3 matches (reweave, prune, undo)
    - `grep -r "_dispatch_post_action_event" src/ztlctl/services/check.py` returns at least 2 matches (fix, rebuild)
    - `grep -r "_dispatch_post_action_event" src/ztlctl/services/graph.py` returns at least 1 match (unlink)
    - `grep -c "_dispatch_post_action_event" src/ztlctl/services/check.py` does NOT count check() method (read-only, no dispatch)
    - tests/services/test_post_action_dispatch.py exists and passes
    - Full test suite passes: `uv run pytest tests/ -x -q`
    - Lint clean: `uv run ruff check src/ztlctl/services/`
    - Type clean: `uv run mypy src/ztlctl/services/`
  </acceptance_criteria>
  <done>All 13+ write service methods call _dispatch_post_action_event after successful commits. ActionEvent payloads flow through the WAL for every mutating operation. Structural regression test prevents future omissions.</done>
</task>

<task type="auto">
  <name>Task 2: Update REQUIREMENTS.md to reflect ARCH-04 and DEBT-02 completion</name>
  <files>
    .planning/REQUIREMENTS.md
  </files>
  <read_first>
    .planning/REQUIREMENTS.md
  </read_first>
  <action>
    Update `.planning/REQUIREMENTS.md` to mark ARCH-04 and DEBT-02 as complete:

    1. **Line 15** — Change `- [ ] **ARCH-04**:` to `- [x] **ARCH-04**:`
    2. **Line 26** — Change `- [ ] **DEBT-02**:` to `- [x] **DEBT-02**:`
    3. **Traceability table line 105** — Change `ARCH-04 | Phase 15 | Pending` to `ARCH-04 | Phase 15 | Complete`
    4. **Traceability table line 113** — Change `DEBT-02 | Phase 15 | Pending` to `DEBT-02 | Phase 15 | Complete`

    These requirements were fully satisfied by Plan 01 (ActionEvent model created, EventBusConfig wired) but the documentation was never updated.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && grep "ARCH-04" .planning/REQUIREMENTS.md && grep "DEBT-02" .planning/REQUIREMENTS.md</automated>
  </verify>
  <acceptance_criteria>
    - `grep "\[x\] \*\*ARCH-04\*\*" .planning/REQUIREMENTS.md` returns 1 match
    - `grep "\[x\] \*\*DEBT-02\*\*" .planning/REQUIREMENTS.md` returns 1 match
    - `grep "ARCH-04.*Pending" .planning/REQUIREMENTS.md` returns 0 matches
    - `grep "DEBT-02.*Pending" .planning/REQUIREMENTS.md` returns 0 matches
    - `grep "ARCH-04.*Complete" .planning/REQUIREMENTS.md` returns 1 match
    - `grep "DEBT-02.*Complete" .planning/REQUIREMENTS.md` returns 1 match
  </acceptance_criteria>
  <done>REQUIREMENTS.md accurately reflects that ARCH-04 (canonical ActionEvent payload model) and DEBT-02 (EventBus timeout configurable) are Complete for Phase 15.</done>
</task>

</tasks>

<verification>
- `grep -c "_dispatch_post_action_event" src/ztlctl/services/create.py src/ztlctl/services/update.py src/ztlctl/services/session.py src/ztlctl/services/reweave.py src/ztlctl/services/check.py src/ztlctl/services/graph.py` — all files show >= 1
- `uv run pytest tests/services/test_post_action_dispatch.py -x -q` — structural test passes
- `uv run pytest tests/ -x -q --timeout=120` — full suite green
- `uv run ruff check src/` — no lint errors
- `uv run mypy src/` — no type errors
- `grep "\[x\].*ARCH-04" .planning/REQUIREMENTS.md` — confirmed complete
- `grep "\[x\].*DEBT-02" .planning/REQUIREMENTS.md` — confirmed complete
</verification>

<success_criteria>
- Every mutating service method dispatches a canonical ActionEvent through the WAL (Gap 1 closed)
- ActionEvent payloads reach the WAL from real service operations (Gap 2 closed, consequence of Gap 1)
- REQUIREMENTS.md correctly marks ARCH-04 and DEBT-02 as Complete (Gap 3 closed)
- Structural regression test prevents future omissions of _dispatch_post_action_event in write services
- Full test suite passes with no regressions
</success_criteria>

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