# Task Recon: Implement Policies CLI Commands

**Task ID:** 25
**Status:** pending
**Generated:** 2026-01-29T21:30:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the policies command group with list, show, and assign commands for voice policy management. This implements the CLI interface for PolicyService, reading policy definitions from CSV cache and user policy assignments from Graph API.

### Dependencies
- Depends on: 8 (OutputFormatter - done), 11 (Root CLI App - done), 17 (UserService - done), 24 (PolicyService - done), 40 (Validation Checkpoint 2 - done)

### Acceptance Criteria
From task details and CLI specification:
- `policies list` - List available voice policies from CSV cache, with optional `--type` filter
- `policies show <user>` - Show policy assignments for a user with assignment sources (direct/group/global)
- `policies assign <user> <policy-type> <policy-name>` - Assign a policy to a user with `--dry-run` support
- All commands must support `--json` output mode
- Show staleness warning when policy cache is outdated
- Handle TeamsVoicemailPolicy limitation (not supported for assignment)

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 8 | OutputFormatter | JSON output via plain `print(json.dumps())`, not Rich's `print_json()`. Use `Console(file=StringIO(), force_terminal=True)` for test output capture. |
| 11 | Root CLI App | `policies_app` is a placeholder in main.py - needs to be replaced with import from new policies.py. CLIContext uses `json_output` field (not `json`). |
| 17 | UserService | UserService not exported from `services/__init__.py` due to circular import - import directly from `teams_phone.services.user_service`. |
| 24 | PolicyService | PolicyService uses `graph_client` and `cache_manager`. TeamsVoicemailPolicy is NOT supported for batch assignment - raises ValidationError. Response from assign is 204 No Content. |

### Reusable Patterns from Dependencies
- **Async execution pattern**: `_run_async()` helper from `users.py:40-68` - creates GraphClient context manager and runs coroutine
- **Staleness check pattern**: `_check_staleness()` helper from `locations.py:77-81`
- **Service creation pattern**: `_get_service()` helper from `locations.py:71-74`
- **Table display pattern**: `formatter.table()` with column list and title - see `locations.py:142-152`
- **JSON output pattern**: Check `cli_ctx.json_output` and call `formatter.json()` - see `locations.py:136-137`
- **Error handling pattern**: Catch `TeamsPhoneError` and call `formatter.error()` with exit code - see `locations.py:153-155`

### Warnings and Gotchas
- **TeamsVoicemailPolicy rejection**: assign_policy raises ValidationError for voicemail policies - CLI should catch and display remediation (use PowerShell)
- **Import order matters**: Import from `teams_phone.infrastructure` first, then services, to avoid circular imports
- **Exit code 2 for no_args_is_help**: Empty command groups with no_args_is_help=True return exit code 2 (Click behavior)
- **Context field naming**: Use `cli_ctx.json_output` not `ctx.obj["json"]` (CLIContext dataclass)

### Open Items Inherited
- None - all dependency tasks are complete

---

## Architecture Context

### Relevant Design Decisions
- **Hybrid Graph API + CSV**: Policies are listed from CSV cache (no Graph API for policy definitions), but user assignments come from Graph API
- **Layered architecture**: CLI -> Service -> Infrastructure (GraphClient/CacheManager)
- **Async operations**: PolicyService methods are async for Graph API calls, sync for CSV operations

### Module Structure
```
src/teams_phone/
├── cli/
│   ├── main.py        # Register policies_app (replace placeholder)
│   └── policies.py    # NEW: policies command group
├── services/
│   └── policy_service.py  # PolicyService (already implemented)
├── models/
│   ├── policy.py      # Policy model
│   └── user.py        # EffectivePolicyAssignment, PolicyAssignmentDetail
```

### Conventions to Follow
- Use `from __future__ import annotations` at top of file
- Use `typer.Typer(name="policies", help="...", no_args_is_help=True)` pattern
- Use `get_context(ctx)` for typed CLIContext access
- Use `@pytest.mark.unit` decorator for all unit tests
- Export the app from the module for registration in main.py

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/locations.py` | 1-296 | **Primary pattern reference** - CSV-based service with staleness warning, list/show/search commands |
| `src/teams_phone/cli/users.py` | 40-68 | `_run_async()` pattern for async service operations |
| `src/teams_phone/cli/numbers.py` | 502-712 | `assign` command pattern with dry-run, confirmation prompt, async execution |
| `src/teams_phone/services/policy_service.py` | 1-264 | PolicyService implementation - list_policies, get_user_policies, assign_policy |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/main.py` | 77-90 | Placeholder policies_app to replace |
| `src/teams_phone/cli/context.py` | 76-93 | `get_context()` helper function |
| `src/teams_phone/models/policy.py` | 1-27 | Policy model (policy_type, policy_name, policy_id, description, is_global) |
| `src/teams_phone/models/user.py` | 48-80 | EffectivePolicyAssignment and PolicyAssignmentDetail models |
| `.taskmaster/docs/cli-specification/command-group-3-policies.md` | 1-176 | Full CLI specification for policies commands |

### Code Patterns to Follow

**Async runner pattern (from users.py:40-68):**
```python
def _run_async(
    ctx: typer.Context,
    coro_factory: Any,
) -> tuple[Any, "OutputFormatter"]:
    """Run an async operation with PolicyService."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()
    config_manager = cli_ctx.get_config_manager()
    cache_manager = CacheManager()
    auth_service = AuthService(config_manager, cache_manager)

    async def _execute() -> Any:
        async with GraphClient(auth_service) as graph_client:
            service = PolicyService(graph_client, cache_manager)
            return await coro_factory(service)

    result = asyncio.run(_execute())
    return result, formatter
```

**Staleness check pattern (from locations.py:77-81):**
```python
def _check_staleness(service: PolicyService, formatter: OutputFormatter) -> None:
    """Check for staleness warning and display if needed."""
    staleness_warning = service.get_staleness_warning()
    if staleness_warning:
        formatter.warning(staleness_warning)
```

**List command pattern (from locations.py:84-155):**
```python
@policies_app.command("list")
def list_policies(
    ctx: typer.Context,
    policy_type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by policy type."),
) -> None:
    """List available voice policies from CSV cache."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        service = _get_service()
        _check_staleness(service, formatter)
        policies = service.list_policies(policy_type=policy_type)

        if cli_ctx.json_output:
            formatter.json([_policy_to_json_dict(p) for p in policies])
        else:
            if not policies:
                formatter.info("No policies found matching the specified criteria")
            else:
                formatter.table(...)
    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)
```

---

## Dependencies

### Internal Modules
- `teams_phone.cli.context` - get_context(), CLIContext
- `teams_phone.exceptions` - TeamsPhoneError, NotFoundError, ValidationError
- `teams_phone.infrastructure` - CacheManager, GraphClient
- `teams_phone.models` - Policy, EffectivePolicyAssignment
- `teams_phone.services` - AuthService
- `teams_phone.services.policy_service` - PolicyService
- `teams_phone.services.user_service` - UserService (for user resolution)

### External Packages
- `typer` - CLI framework
- `asyncio` - Async execution

### New Dependencies Needed
- None required - all dependencies already in project

---

## Testing Guidance

### Test File Location
`tests/unit/test_cli_policies.py`

### Test Patterns in Use
```python
# Example test structure (tests/unit/test_cli_locations.py:47-74)
class TestPoliciesListCommand:
    """Tests for policies list command."""

    @pytest.mark.unit
    def test_list_shows_in_help(self) -> None:
        """list command appears in policies help."""
        result = runner.invoke(app, ["policies", "--help"])
        assert result.exit_code == 0
        assert "list" in result.stdout

    @pytest.mark.unit
    def test_list_json_output(self) -> None:
        """list outputs valid JSON with --json flag."""
        with (
            patch("teams_phone.cli.policies.CacheManager") as mock_cm_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
        ):
            mock_cm = MagicMock()
            mock_cm_class.return_value = mock_cm
            mock_service = MagicMock()
            mock_service.list_policies.return_value = [_make_policy()]
            mock_service.get_staleness_warning.return_value = None
            mock_ps_class.return_value = mock_service

            result = runner.invoke(app, ["--json", "policies", "list"])
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert len(data) == 1
```

### Fixtures Available
- `runner = CliRunner()` - Typer CLI test runner
- `tmp_path` - pytest built-in for temp directories

### Test Utilities
- `from typer.testing import CliRunner` - CLI testing
- `from unittest.mock import MagicMock, patch, AsyncMock` - Mocking
- `from teams_phone.cli.main import app` - Main CLI app for invocation

---

## Risks and Blockers

### Technical Risks
- [x] **User resolution in show/assign**: Need to resolve user identifier (UPN/name/ID) to user_id before calling PolicyService.get_user_policies(). Use UserService.resolve_user() pattern from users.py.
- [x] **Progress spinner for assign**: assign_policy returns 204 immediately (no async polling needed) - simpler than numbers assign. May not need progress spinner.

### Dependency Risks
- None - all dependency tasks are complete

### Open Questions
- [ ] **Policy type CLI names vs API names**: CLI spec mentions short names (calling, voicemail) but PolicyService uses full names (TeamsCallingPolicy). Need mapping dict like:
  ```python
  POLICY_TYPE_MAP = {
      "calling": "TeamsCallingPolicy",
      "voicemail": "TeamsVoicemailPolicy",
      "caller-id": "CallingLineIdentity",
      "dial-plan": "TenantDialPlan",
      "voice-routing": "OnlineVoiceRoutingPolicy",
      "emergency": "TeamsEmergencyCallingPolicy",
      "emergency-routing": "TeamsEmergencyCallRoutingPolicy",
  }
  ```
- [ ] **Global policy name**: CLI spec mentions using "Global" to reset to default. Need to verify if this is a special case or if "Global" is the actual policy name in CSV.

---

## Implementation Recommendations

### Suggested Approach
1. **Create `src/teams_phone/cli/policies.py`** with:
   - `policies_app` Typer instance
   - `POLICY_TYPE_MAP` dict for CLI-to-API type mapping
   - `_get_service()` helper for sync PolicyService creation (list only needs cache)
   - `_run_async()` helper for async PolicyService operations
   - `_check_staleness()` helper
   - Data conversion helpers: `_policy_to_list_dict()`, `_policy_to_json_dict()`, `_assignment_to_list_dict()`, `_assignment_to_json_dict()`

2. **Implement `list` command**:
   - Accept optional `--type` filter (map CLI name to API name)
   - Show staleness warning
   - Display: policy_type, policy_name, description, is_global
   - Support `--json` output

3. **Implement `show` command**:
   - Accept user argument (UPN, display name, or object ID)
   - Resolve user via UserService.resolve_user()
   - Get policies via PolicyService.get_user_policies()
   - Display: policy_type, policy_name, assignment_type (direct/group/global)
   - Support `--json` output

4. **Implement `assign` command**:
   - Accept user, policy-type, policy-name arguments
   - Support `--dry-run` flag
   - Support `--force` flag to skip confirmation
   - Resolve user via UserService.resolve_user()
   - Call PolicyService.assign_policy()
   - Handle ValidationError for TeamsVoicemailPolicy with remediation message

5. **Update `src/teams_phone/cli/main.py`**:
   - Import `policies_app` from `teams_phone.cli.policies`
   - Remove placeholder `policies_app` definition
   - Keep the `app.add_typer()` registration

6. **Create unit tests in `tests/unit/test_cli_policies.py`**

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/cli/policies.py` | New policies command group |
| Modify | `src/teams_phone/cli/main.py` | Import and register policies_app |
| Create | `tests/unit/test_cli_policies.py` | Unit tests for CLI commands |

### Quick Wins
- Start with `list` command - simplest, only uses sync CacheManager operations
- Use locations.py as template - closest pattern match (CSV-based service with staleness)

---

## Cheat Sheet

### Key Imports
```python
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING, Any, Optional

import typer

from teams_phone.cli.context import get_context
from teams_phone.exceptions import TeamsPhoneError, ValidationError
from teams_phone.infrastructure import CacheManager, GraphClient
from teams_phone.models import EffectivePolicyAssignment, Policy
from teams_phone.services import AuthService
from teams_phone.services.policy_service import PolicyService
from teams_phone.services.user_service import UserService

if TYPE_CHECKING:
    from teams_phone.infrastructure import OutputFormatter
```

### Key Functions to Use
- `get_context(ctx) -> CLIContext` - Get typed context from Typer context
- `cli_ctx.get_output_formatter() -> OutputFormatter` - Get formatter instance
- `cli_ctx.get_config_manager() -> ConfigManager` - Get config (lazy init)
- `PolicyService.list_policies(policy_type=None) -> list[Policy]` - List from CSV
- `PolicyService.get_user_policies(user_id, policy_type=None) -> list[EffectivePolicyAssignment]` - Get user assignments
- `PolicyService.assign_policy(user_id, policy_type, policy_name) -> None` - Assign policy
- `PolicyService.get_staleness_warning() -> str | None` - Check cache freshness
- `UserService.resolve_user(identifier) -> UserConfiguration` - Resolve user by UPN/name/ID

### Common Patterns
- **Async execution**: `asyncio.run(_execute())` inside `_run_async()` helper
- **Service creation for list**: Sync - `PolicyService(graph_client=None, cache_manager=CacheManager())` won't work - need mock GraphClient or refactor
- **Actually**: Looking at PolicyService, it requires graph_client in constructor even for list_policies (which only uses cache). For list command, create a MagicMock for graph_client or use the full _run_async pattern.
- **Error handling**: `except TeamsPhoneError as e: formatter.error(e.message, remediation=e.remediation); raise typer.Exit(e.exit_code)`
- **JSON vs table**: `if cli_ctx.json_output: formatter.json(...) else: formatter.table(...)`

### Policy Type Mapping
```python
POLICY_TYPE_MAP = {
    "calling": "TeamsCallingPolicy",
    "voicemail": "TeamsVoicemailPolicy",
    "caller-id": "CallingLineIdentity",
    "dial-plan": "TenantDialPlan",
    "voice-routing": "OnlineVoiceRoutingPolicy",
    "emergency": "TeamsEmergencyCallingPolicy",
    "emergency-routing": "TeamsEmergencyCallRoutingPolicy",
}
```

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
