---
phase: 06-agentic-integration-security
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/services/result.py
  - src/ztlctl/mcp/response.py
  - tests/services/test_result.py
  - tests/mcp/test_response.py
autonomous: true
requirements:
  - AGNT-01

must_haves:
  truths:
    - "Every ServiceResult error includes a machine-readable recovery field with actionable next steps"
    - "McpResponse.from_result() propagates recovery from ServiceError or COMMON_ERROR_RECOVERY fallback"
    - "All ~35 error codes across all services have recovery entries in COMMON_ERROR_RECOVERY"
  artifacts:
    - path: "src/ztlctl/services/result.py"
      provides: "ServiceError with recovery: str | None = None field"
      contains: "recovery: str | None = None"
    - path: "src/ztlctl/mcp/response.py"
      provides: "McpError with recovery field + extended COMMON_ERROR_RECOVERY dict"
      contains: "ALREADY_OPEN"
  key_links:
    - from: "src/ztlctl/mcp/response.py"
      to: "src/ztlctl/services/result.py"
      via: "McpResponse.from_result() reads result.error.recovery"
      pattern: "result\\.error\\.recovery"
---

<objective>
Add machine-readable error recovery guidance to ServiceError and McpError models, and extend COMMON_ERROR_RECOVERY to cover all error codes across all services.

Purpose: Agents currently receive error codes but no actionable guidance on what to do next. Adding a `recovery` field to ServiceError and populating COMMON_ERROR_RECOVERY for all ~35 error codes gives agents programmatic next-step instructions for every failure mode.

Output: Updated ServiceError/McpError Pydantic models with recovery field; COMMON_ERROR_RECOVERY extended from 9 to ~35 entries; McpResponse.from_result() wired to propagate recovery.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-agentic-integration-security/06-RESEARCH.md

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

From src/ztlctl/services/result.py:
```python
class ServiceError(BaseModel):
    model_config = {"frozen": True}
    code: str
    message: str
    detail: dict[str, Any] = Field(default_factory=dict)

class ServiceResult(BaseModel):
    model_config = {"frozen": True}
    ok: bool
    op: str
    data: dict[str, Any] = Field(default_factory=dict)
    warnings: list[str] = Field(default_factory=list)
    error: ServiceError | None = None
    meta: dict[str, Any] | None = None
```

From src/ztlctl/mcp/response.py:
```python
COMMON_ERROR_RECOVERY: dict[str, str] = {
    "NOT_FOUND": "Verify the target ID with search(), list_items(), or get_document().",
    "VALIDATION_FAILED": "Check required params and field types...",
    "ID_COLLISION": "Search for the existing item first...",
    "NO_ACTIVE_SESSION": "Start a session with create_log(topic)...",
    "ACTIVE_SESSION_EXISTS": "Use session_status() to inspect...",
    "INVALID_TRANSITION": "Inspect the current item with get_document()...",
    "EMPTY_QUERY": "Provide a non-empty search string...",
    "UNKNOWN_TYPE": "Use a supported content type/subtype...",
    "NO_PATH": "Confirm both IDs exist and are connected...",
}

class McpError(BaseModel):
    model_config = {"frozen": True}
    code: str
    message: str
    detail: dict[str, Any] = Field(default_factory=dict)

class McpResponse(BaseModel):
    ...
    @classmethod
    def from_result(cls, result: ServiceResult) -> McpResponse:
        error: McpError | None = None
        if result.error is not None:
            error = McpError(
                code=result.error.code,
                message=result.error.message,
            )
        return cls(ok=result.ok, op=result.op, data=result.data,
                   warnings=result.warnings if result.warnings else None, error=error)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add recovery field to ServiceError and McpError, extend COMMON_ERROR_RECOVERY</name>
  <files>src/ztlctl/services/result.py, src/ztlctl/mcp/response.py, tests/services/test_result.py, tests/mcp/test_response.py</files>
  <read_first>
    - src/ztlctl/services/result.py
    - src/ztlctl/mcp/response.py
    - tests/services/test_result.py
    - tests/mcp/test_response.py
  </read_first>
  <behavior>
    - ServiceError(code="X", message="Y") still works (recovery defaults to None)
    - ServiceError(code="X", message="Y", recovery="Do Z") stores recovery="Do Z"
    - McpError(code="X", message="Y", recovery="Do Z") stores recovery="Do Z"
    - McpResponse.from_result() with error.recovery set propagates recovery to McpError
    - McpResponse.from_result() with error.recovery=None falls back to COMMON_ERROR_RECOVERY[code]
    - McpResponse.from_result() with error.recovery=None and code NOT in COMMON_ERROR_RECOVERY results in McpError.recovery=None
    - model_dump(exclude_none=True) on McpError omits recovery when None
    - model_dump(exclude_none=True) on McpError includes recovery when set
    - COMMON_ERROR_RECOVERY has entries for all known error codes (at least 30 entries)
  </behavior>
  <action>
**Step 1 — Write tests first (RED):**

In `tests/services/test_result.py`, add:
- `test_service_error_recovery_field_default_none`: Construct `ServiceError(code="X", message="Y")`, assert `error.recovery is None`.
- `test_service_error_with_recovery`: Construct `ServiceError(code="X", message="Y", recovery="Do Z")`, assert `error.recovery == "Do Z"`.

In `tests/mcp/test_response.py`, add:
- `test_mcp_error_recovery_field`: Construct `McpError(code="X", message="Y", recovery="R")`, assert `error.recovery == "R"`.
- `test_recovery_populated_from_common_dict`: Create `ServiceResult(ok=False, op="test", error=ServiceError(code="NOT_FOUND", message="gone"))`. Call `McpResponse.from_result(result)`. Assert `response.error.recovery == COMMON_ERROR_RECOVERY["NOT_FOUND"]`.
- `test_recovery_from_service_error_overrides_dict`: Create `ServiceResult(ok=False, op="test", error=ServiceError(code="NOT_FOUND", message="gone", recovery="custom recovery"))`. Call `McpResponse.from_result(result)`. Assert `response.error.recovery == "custom recovery"` (ServiceError.recovery takes priority over COMMON_ERROR_RECOVERY).
- `test_recovery_none_when_code_not_in_dict`: Create `ServiceResult(ok=False, op="test", error=ServiceError(code="UNKNOWN_CODE_XYZ", message="x"))`. Assert `McpResponse.from_result(result).error.recovery is None`.
- `test_recovery_excluded_when_none_in_dump`: Construct `McpError(code="X", message="Y")`, call `model_dump(exclude_none=True)`, assert `"recovery" not in dump`.
- `test_recovery_included_when_set_in_dump`: Construct `McpError(code="X", message="Y", recovery="R")`, call `model_dump(exclude_none=True)`, assert `dump["recovery"] == "R"`.
- `test_all_codes_have_recovery`: Grep all unique `ServiceError(code=` strings across `src/ztlctl/services/`. Assert every code string appears as a key in `COMMON_ERROR_RECOVERY`. Use a manual set of all known codes (see below) rather than runtime grep.

Run tests — all new tests should FAIL (RED).

**Step 2 — Add recovery field to ServiceError (GREEN for result.py tests):**

In `src/ztlctl/services/result.py`, add to `ServiceError`:
```python
recovery: str | None = None
```
After the `detail` field. Default=None ensures all 30+ existing construction sites work without modification.

**Step 3 — Add recovery field to McpError and wire from_result() (GREEN for response.py tests):**

In `src/ztlctl/mcp/response.py`:

1. Add to `McpError`:
```python
recovery: str | None = None
```

2. Update `from_result()`:
```python
if result.error is not None:
    recovery = result.error.recovery or COMMON_ERROR_RECOVERY.get(result.error.code)
    error = McpError(
        code=result.error.code,
        message=result.error.message,
        recovery=recovery,
    )
```

3. Extend `COMMON_ERROR_RECOVERY` with all missing codes. Add these entries after the existing 9:

```python
"ALREADY_OPEN": "The session is already open. Use session_status() to inspect it or close() to end it.",
"NO_ENTRIES": "The session has no entries. Add notes or references before closing.",
"NO_HISTORY": "No undo history exists for this note. Call reweave() without undo to create a new suggestion.",
"NO_LINK": "No link exists between these nodes. Use link() to create a connection first.",
"BATCH_PARTIAL": "Some items in the batch failed. Check the data.results list for per-item status.",
"BATCH_FAILED": "All batch items failed. Validate your input list and retry individual creates.",
"INIT_STEP_FAILED": "An init step failed. Check the error detail for the step name and retry init.",
"INVALID_PROFILE": "The profile name is not recognized. Call list_profiles to see valid options.",
"VAULT_EXISTS": "A vault already exists at this path. Use a different directory or remove the existing vault.",
"NO_CONFIG": "No ztlctl.toml found. Run ztlctl init to create a vault configuration first.",
"PROFILE_NOT_FOUND": "The requested profile is not installed. Check available profiles with list_profiles.",
"NOT_A_VAULT": "This directory is not a ztlctl vault. Run ztlctl init first or change to your vault directory.",
"WORKFLOW_EXISTS": "Workflow is already initialized. Use workflow_update to modify it.",
"WORKFLOW_NOT_INITIALIZED": "Workflow is not initialized. Run workflow_init first.",
"WORKFLOW_INIT_FAILED": "Copier template application failed. Check that the vault directory is writable.",
"WORKFLOW_UPDATE_FAILED": "Copier update failed. Try running workflow_init with force to reinitialize.",
"WORKFLOW_VALIDATION_FAILED": "Workflow asset validation failed. Run workflow_export to regenerate assets.",
"CHECK_FAILED": "Schema check failed. Run check() to inspect vault integrity before upgrading.",
"BACKUP_FAILED": "Backup creation failed. Ensure the vault directory is writable before upgrading.",
"MIGRATION_FAILED": "Migration failed. Restore from backup with check_restore() and retry.",
"STAMP_FAILED": "Schema stamp failed. Run check_pending() to inspect migration state.",
"INVALID_FORMAT": "Unknown export format. Use one of: markdown, indexes, dot, json.",
"INVALID_VIEWER": "Unknown viewer. Use one of: vanilla, claude, codex.",
"NO_BACKUPS": "No backups found. Run check_backup() to create one.",
"SEMANTIC_UNAVAILABLE": "Semantic search is unavailable. Install the vector extra: pip install ztlctl[vector].",
"UNSUPPORTED_INPUT": "Unsupported input kind. Use text or url.",
"NO_PROVIDER": "No source provider found for this URL scheme. Install a plugin that supports this scheme.",
```

**Step 4 — Run tests (GREEN):**
Run `uv run pytest tests/services/test_result.py tests/mcp/test_response.py -x`. All tests pass.

**Step 5 — Full suite validation:**
Run `uv run ruff check . && uv run ruff format --check . && uv run mypy src/ && uv run pytest` to confirm nothing is broken.
  </action>
  <verify>
    <automated>uv run pytest tests/services/test_result.py tests/mcp/test_response.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "recovery: str | None = None" src/ztlctl/services/result.py
    - grep -q "recovery: str | None = None" src/ztlctl/mcp/response.py
    - grep -q "result.error.recovery or COMMON_ERROR_RECOVERY" src/ztlctl/mcp/response.py
    - grep -q "ALREADY_OPEN" src/ztlctl/mcp/response.py
    - grep -q "SEMANTIC_UNAVAILABLE" src/ztlctl/mcp/response.py
    - grep -q "test_service_error_recovery_field_default_none" tests/services/test_result.py
    - grep -q "test_recovery_populated_from_common_dict" tests/mcp/test_response.py
    - grep -q "test_all_codes_have_recovery" tests/mcp/test_response.py
    - uv run pytest tests/services/test_result.py tests/mcp/test_response.py -x -q passes
  </acceptance_criteria>
  <done>ServiceError and McpError have recovery field; from_result() propagates recovery with ServiceError.recovery overriding COMMON_ERROR_RECOVERY fallback; COMMON_ERROR_RECOVERY has entries for all ~35 error codes; all new tests pass; full suite green.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/services/test_result.py tests/mcp/test_response.py -x` passes
- `uv run ruff check . && uv run mypy src/` passes
- `uv run pytest` full suite passes (no regressions)
- `grep -c "recovery" src/ztlctl/mcp/response.py` shows recovery wired in McpError and from_result
</verification>

<success_criteria>
1. ServiceError.recovery field exists with None default — all existing construction sites unaffected
2. McpError.recovery field exists, propagated by from_result() with ServiceError.recovery > COMMON_ERROR_RECOVERY fallback
3. COMMON_ERROR_RECOVERY covers all error codes (at least 30 entries)
4. All tests pass including new recovery-specific tests
</success_criteria>

<output>
After completion, create `.planning/phases/06-agentic-integration-security/06-01-SUMMARY.md`
</output>
