# Task Recon: Implement list_policies and validate_policy methods for CSV cache reading

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

---

## Task Summary

Create the PolicyService class with constructor, list_policies, and validate_policy methods that read policies from CSV cache via CacheManager. This subtask implements the foundation for policy operations by providing CSV-based policy listing, type filtering, and validation with proper error handling. The implementation should follow the LocationService pattern closely.

### Parent Task Context

**Parent Task:** Implement PolicyService (ID: 24)
**Parent Goal:** Create the PolicyService for listing policies from CSV, showing user policy assignments, and assigning policies via Graph API. This service provides the policy management layer for the Teams Phone CLI.
**This Subtask's Role:** Establishes the core PolicyService class with CSV reading capabilities, enabling subsequent subtasks to build on this foundation for user policy retrieval (24.2) and policy assignment (24.3).

### Dependencies
- Task 5: CacheManager - Provides `read_policies_csv()` method (DONE)
- Task 7: GraphClient - Required for constructor but not used in this subtask (DONE)
- Task 9: Pydantic Models - Policy model already exists (DONE)
- Task 40: Validation Checkpoint 2 - Read operations validated (DONE)

### Acceptance Criteria
1. PolicyService class with `__init__(self, graph_client: GraphClient, cache_manager: CacheManager)`
2. `list_policies(policy_type: str | None = None) -> list[Policy]` method that:
   - Reads policies from CSV cache via `cache_manager.read_policies_csv()`
   - Converts `isGlobal` string to boolean
   - Validates each row with `Policy.model_validate(validated_row)`
   - Filters by policy_type if provided (case-insensitive)
   - Returns empty list if CSV missing (graceful degradation)
3. `validate_policy(policy_type: str, policy_name: str) -> Policy` method that:
   - Finds matching policy by type AND name (case-insensitive)
   - Raises NotFoundError with remediation if not found
4. Cache staleness methods: `get_cache_age()`, `is_cache_stale()`, `get_staleness_warning()`
5. Export PolicyService in `services/__init__.py`

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 5 | CacheManager | `read_policies_csv()` returns `list[dict[str, str]]` - all values are strings including booleans; uses UTF-8-sig encoding for PowerShell BOM handling |
| 5.2 | CSV Reading | Empty list returned for missing/malformed CSV files (graceful degradation); use `cache_manager.is_cache_stale()` for staleness detection |
| 5.3 | Cache Staleness | `get_cache_age_days()` and `is_cache_stale()` use oldest mtime of both CSV files; `CSV_STALE_DAYS = 30` from constants |
| 7 | GraphClient | Not needed for this subtask, but must be accepted in constructor for future subtasks |
| 9.3 | Policy Model | Policy model has `extra="forbid"` - strict validation; uses aliases like `policyType`, `isGlobal` for CSV column names |

### Reusable Patterns from Dependencies
- **Boolean conversion pattern**: `validated_row["isGlobal"] = row.get("isGlobal", "").lower() == "true"` - see `location_service.py:52`
- **CSV to Pydantic validation**: `Policy.model_validate(validated_row)` after boolean conversion
- **Staleness methods delegation**: Delegate to `cache_manager.is_cache_stale()`, `get_cache_age_days()` - see `location_service.py:166-194`
- **NotFoundError with remediation**: Include CLI command suggestion - see `location_service.py:119-126`

### Warnings and Gotchas
- **All CSV values are strings**: CacheManager returns `dict[str, str]` - must convert `isGlobal` to bool before Pydantic validation
- **extra="forbid" on Policy model**: Any extra fields in CSV will cause validation failure - this is intentional for contract enforcement
- **Case-insensitive matching**: Use `.lower()` for both policy_type and policy_name comparisons in validate_policy

### Open Items Inherited
- None directly affecting this subtask

---

## Architecture Context

### Relevant Design Decisions
- **CSV cache for policies**: Graph API has no endpoint to list policy definitions, so PowerShell exports are cached locally
- **Graceful degradation**: Empty list returned when CSV missing (like LocationService)
- **Strict Pydantic validation**: `extra="forbid"` catches schema changes in CSV exports

### Module Structure
```
src/teams_phone/
├── services/
│   ├── __init__.py          # Export PolicyService (modify)
│   ├── location_service.py  # Pattern to follow
│   └── policy_service.py    # Create this file
└── models/
    └── policy.py            # Policy model (exists)
```

### Conventions to Follow
- Services accept infrastructure dependencies via constructor injection
- TYPE_CHECKING import for type hints to avoid circular imports
- Docstrings for all public methods with Args, Returns, Raises sections
- Use `list[Policy]` not `List[Policy]` (Python 3.10+ style)

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/location_service.py` | 1-195 | Complete pattern to follow for CSV-based service |
| `src/teams_phone/services/location_service.py` | 38-55 | `list_locations()` with boolean conversion pattern |
| `src/teams_phone/services/location_service.py` | 101-127 | `validate_location()` with NotFoundError pattern |
| `src/teams_phone/services/location_service.py` | 153-194 | Cache staleness methods to replicate |
| `src/teams_phone/models/policy.py` | 1-27 | Policy Pydantic model with aliases |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/infrastructure/cache_manager.py` | 186-194 | `read_policies_csv()` implementation |
| `src/teams_phone/infrastructure/cache_manager.py` | 246-274 | Staleness detection methods |
| `src/teams_phone/exceptions.py` | 72-83 | NotFoundError definition |
| `src/teams_phone/constants.py` | 44 | CSV_STALE_DAYS = 30 |
| `src/teams_phone/services/__init__.py` | 1-18 | Service exports pattern |

### Code Patterns to Follow

```python
# Boolean conversion pattern from LocationService (location_service.py:48-53)
for row in raw_data:
    validated_row: dict[str, Any] = dict(row)
    validated_row["isDefault"] = row.get("isDefault", "").lower() == "true"
    locations.append(EmergencyLocation.model_validate(validated_row))
```

```python
# NotFoundError pattern from LocationService (location_service.py:119-126)
raise NotFoundError(
    f"Location '{identifier}' not found",
    remediation=(
        "Verify the location ID or description is correct.\n"
        "Use 'teams-phone locations list' to see available locations."
    ),
)
```

```python
# Cache staleness methods from LocationService (location_service.py:153-194)
def get_cache_age(self) -> timedelta | None:
    age_days = self.cache_manager.get_cache_age_days()
    if age_days is None:
        return None
    return timedelta(days=age_days)

def is_cache_stale(self) -> bool:
    return self.cache_manager.is_cache_stale()

def get_staleness_warning(self) -> str | None:
    if not self.is_cache_stale():
        return None
    age_days = self.cache_manager.get_cache_age_days()
    if age_days is None:
        return "Policy cache not found. Run the PowerShell export script..."
    return f"Policy cache is {age_days} days old..."
```

---

## Dependencies

### Internal Modules
- `teams_phone.infrastructure.CacheManager` - CSV reading and staleness detection
- `teams_phone.infrastructure.GraphClient` - Required for constructor (not used in this subtask)
- `teams_phone.models.Policy` - Pydantic model for validation
- `teams_phone.exceptions.NotFoundError` - For validate_policy errors
- `teams_phone.constants.CSV_STALE_DAYS` - For staleness warning message

### External Packages
- `pydantic` - Model validation (already installed)
- `typing` - TYPE_CHECKING, Any (stdlib)
- `datetime` - timedelta (stdlib)

### New Dependencies Needed
- None required

---

## Testing Guidance

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

### Test Patterns in Use
```python
# From test_location_service.py - CSV fixture pattern
POLICIES_CSV = """policyType,policyName,policyId,description,isGlobal
TeamsCallingPolicy,AllowCalling,policy-id-1,Allows all calling features,False
TeamsCallingPolicy,Global,global-policy-id,,True
TenantDialPlan,US-DialPlan,dialplan-id-1,US dialing rules,False"""

# Test class structure pattern
class TestPolicyServiceListPolicies:
    @pytest.mark.unit
    def test_list_policies_returns_empty_list_when_no_csv(self, tmp_path: Path) -> None:
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)
        result = service.list_policies()
        assert result == []
```

### Fixtures Available
- `tmp_path` - pytest built-in for temporary directory
- Write CSV files directly to `tmp_path / "policies.csv"`

### Test Utilities
- `from unittest.mock import MagicMock` - For mocking GraphClient (not used in these methods)
- `CacheManager(cache_dir=str(tmp_path))` - Real CacheManager with temp directory

### Tests to Write
1. `test_init_stores_cache_manager_and_graph_client` - Constructor stores both dependencies
2. `test_list_policies_returns_empty_list_when_no_csv` - Graceful degradation
3. `test_list_policies_returns_policy_objects` - Returns list[Policy]
4. `test_list_policies_converts_boolean_correctly` - isGlobal conversion
5. `test_list_policies_filters_by_type` - Type filtering works
6. `test_list_policies_filter_is_case_insensitive` - "teamscallingpolicy" matches
7. `test_list_policies_no_filter_returns_all` - None filter returns all
8. `test_validate_policy_finds_exact_match` - Type AND name match
9. `test_validate_policy_is_case_insensitive` - Case-insensitive matching
10. `test_validate_policy_raises_not_found_error` - NotFoundError with remediation
11. `test_validate_policy_raises_when_no_csv` - NotFoundError when CSV missing
12. `test_get_cache_age_returns_timedelta` - Staleness method
13. `test_is_cache_stale_returns_true_when_no_cache` - Staleness detection
14. `test_get_staleness_warning_returns_message` - Warning message format

---

## Risks and Blockers

### Technical Risks
- [x] **Low risk**: Policy model already exists and is tested - no schema changes needed

### Dependency Risks
- [x] **No blockers**: All dependency tasks (5, 7, 9, 40) are complete

### Open Questions
- [ ] Should `validate_policy` accept policy_id as an alternative to policy_name? (LocationService accepts location_id OR description) - **Recommendation**: For now, require both type AND name to match; policy_id can be added later if needed
- [ ] Should we add a `get_policy()` method like LocationService has `get_location()`? - **Recommendation**: Not needed for subtask 24.1; can add in future if CLI needs it

---

## Implementation Recommendations

### Suggested Approach
1. Create `src/teams_phone/services/policy_service.py` with imports
2. Add `SUPPORTED_POLICY_TYPES` constant for documentation (not enforced in list_policies)
3. Implement `__init__` storing both graph_client and cache_manager
4. Implement `list_policies()` following LocationService pattern
5. Implement `validate_policy()` with case-insensitive type+name matching
6. Implement cache staleness methods (copy from LocationService, adjust messages)
7. Update `services/__init__.py` to export PolicyService
8. Create comprehensive unit tests in `tests/unit/test_policy_service.py`

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/services/policy_service.py` | New PolicyService class |
| Modify | `src/teams_phone/services/__init__.py` | Export PolicyService |
| Create | `tests/unit/test_policy_service.py` | Unit tests |

### Quick Wins
- Start with the constructor and `list_policies()` - they're straightforward adaptations of LocationService
- Boolean conversion pattern is identical to LocationService (just different field name)

---

## Cheat Sheet

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

from datetime import timedelta
from typing import TYPE_CHECKING, Any

from teams_phone.constants import CSV_STALE_DAYS
from teams_phone.exceptions import NotFoundError
from teams_phone.models import Policy

if TYPE_CHECKING:
    from teams_phone.infrastructure import CacheManager, GraphClient
```

### Key Functions to Use
- `cache_manager.read_policies_csv()` - Returns `list[dict[str, str]]`
- `Policy.model_validate(validated_row)` - Validates and creates Policy object
- `cache_manager.get_cache_age_days()` - Returns `int | None`
- `cache_manager.is_cache_stale()` - Returns `bool`

### Common Patterns
- **Boolean conversion**: `validated_row["isGlobal"] = row.get("isGlobal", "").lower() == "true"`
- **Case-insensitive comparison**: `policy.policy_type.lower() == policy_type.lower()`
- **NotFoundError with remediation**: Include `"Use 'teams-phone policies list' to see available policies."`

### Supported Policy Types (for reference)
```python
SUPPORTED_POLICY_TYPES = [
    "TeamsCallingPolicy",
    "TeamsVoicemailPolicy",
    "CallingLineIdentity",
    "TenantDialPlan",
    "OnlineVoiceRoutingPolicy",
    "TeamsEmergencyCallingPolicy",
    "TeamsEmergencyCallRoutingPolicy",
]
```

---

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