# Task Recon: Implement Policy, EmergencyLocation (CSV), and GraphListResponse wrapper models

**Task ID:** 9.3
**Status:** pending
**Generated:** 2026-01-28T22:00:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create three model files for the Teams Phone CLI:
1. `src/teams_phone/models/policy.py` - Policy model for CSV-sourced policy data
2. `src/teams_phone/models/location.py` - EmergencyLocation model for CSV-sourced location data
3. `src/teams_phone/models/api_responses.py` - Generic GraphListResponse[T] for paginated API responses and GraphErrorResponse for error handling

These models complete the Pydantic model layer for the Teams Phone CLI, covering both CSV cache data (policies, locations exported via PowerShell) and generic Graph API response wrappers.

### Parent Task Context

**Parent Task:** Implement Pydantic Models for Graph API Responses (ID: 9)
**Parent Goal:** Create strict Pydantic models for all Microsoft Graph API response schemas with `extra='forbid'` for contract validation, enabling fail-fast behavior when the API changes unexpectedly.
**This Subtask's Role:** Implements the final set of models:
- **CSV models** (Policy, EmergencyLocation): Validate data exported via PowerShell script since Graph API doesn't expose these endpoints
- **Generic response wrappers** (GraphListResponse[T], GraphErrorResponse): Provide type-safe parsing for paginated list responses and error responses across all Graph API calls

### Dependencies
- Depends on: Subtask 9.1 (UserConfiguration - DONE), Subtask 9.2 (NumberAssignment - DONE)

### Acceptance Criteria
1. Create `Policy` model with fields: policy_type, policy_name, policy_id, description, is_global
2. Create `EmergencyLocation` model with fields: location_id, civic_address_id, description, company_name, address, city, state_or_province, postal_code, country_or_region, is_default
3. Create `GraphListResponse[T]` generic model with OData fields: @odata.context, @odata.count, @odata.nextLink, value
4. Create `GraphErrorDetail` with self-referential nested details
5. Create `GraphErrorResponse` wrapper for error handling
6. All models use `ConfigDict(extra="forbid")` - CSV models don't need `populate_by_name` since no alias mapping needed
7. Graph API response models use `ConfigDict(extra="forbid", populate_by_name=True)` for OData aliases
8. Update `__init__.py` to export all new models
9. Unit tests for all models including generic type instantiation

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 9.1 | Implement UserConfiguration models | Pattern established: `(str, Enum)` for StrEnum, `ConfigDict(extra="forbid", populate_by_name=True)` for Graph API models. Tests use camelCase aliases directly. |
| 9.2 | Implement NumberAssignment models | `@odata.type` alias pattern works correctly: `Field(alias="@odata.type")`. datetime fields auto-parse ISO strings. Use `model_validate()` for dict input in tests. |

### Reusable Patterns from Dependencies
- **StrEnum pattern**: `class PolicyType(str, Enum):` - see `models/user.py:14-29` (AccountType)
- **ConfigDict without alias**: `ConfigDict(extra="forbid")` - see `models/cache.py:21` (for CSV models that don't need aliases)
- **OData alias pattern**: `Field(alias="@odata.context")` - see `models/number.py:125` (`@odata.type` pattern)
- **Optional field with default**: `description: Optional[str] = Field(default=None)` - see `models/user.py:105`
- **Model with ConfigDict**: `model_config = ConfigDict(extra="forbid", populate_by_name=True)` - see `models/number.py:104`

### Warnings and Gotchas
- **CSV models don't need aliases**: The PowerShell export script uses snake_case column names (e.g., `policy_type`, `location_id`), so no camelCase alias mapping is needed for Policy and EmergencyLocation.
- **Generic models in Pydantic v2**: Use `Generic[T]` from typing with `BaseModel`. The generic type parameter is resolved at validation time.
- **Self-referential models**: For GraphErrorDetail with nested details, define the type annotation as `list["GraphErrorDetail"] | None` with forward reference.
- **@odata prefixed fields**: Use `Field(alias="@odata.context")` pattern - the `@` character is supported in aliases.

### Open Items Inherited
- None - subtasks 9.1 and 9.2 are complete with no open items affecting this task.

---

## Architecture Context

### Relevant Design Decisions
- **Pydantic strict mode**: All models use `extra='forbid'` for contract validation (see CLAUDE.md:50-51)
- **CSV cache pattern**: Policies and locations come from PowerShell CSV export, not Graph API (see CLAUDE.md:49)
- **Generic response wrapper**: GraphListResponse[T] provides consistent handling of paginated responses across all list endpoints

### Module Structure
```
src/teams_phone/models/
├── __init__.py          # Export all models (UPDATE)
├── auth.py              # AuthStatus, CachedToken (existing)
├── cache.py             # CacheMetadata (existing)
├── config.py            # Config, CacheSettings, NetworkSettings (existing)
├── tenant.py            # TenantProfile, AuthMethod (existing)
├── user.py              # UserConfiguration, etc. (from 9.1)
├── number.py            # NumberAssignment, AsyncOperation (from 9.2)
├── policy.py            # NEW: Policy model for CSV data
├── location.py          # NEW: EmergencyLocation model for CSV data
└── api_responses.py     # NEW: GraphListResponse[T], GraphErrorResponse
```

### Conventions to Follow
- Model files named after the domain entity
- All models inherit from `BaseModel`
- CSV models: `ConfigDict(extra="forbid")` - no aliases needed
- Graph API models: `ConfigDict(extra="forbid", populate_by_name=True)` with aliases
- Module docstrings describe the purpose
- Class docstrings list attributes with types and descriptions

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/cache.py` | 8-25 | Simple model with `ConfigDict(extra="forbid")` only - pattern for CSV models |
| `src/teams_phone/models/number.py` | 125 | `@odata.type` alias pattern - use for OData fields in GraphListResponse |
| `src/teams_phone/models/user.py` | 100-118 | Model with many fields - reference for docstring style |
| `scripts/Export-TeamsPhoneData.ps1` | 214-226 | CSV column names for location export (snake_case) |
| `scripts/Export-TeamsPhoneData.ps1` | 275-281 | CSV column names for policy export (snake_case) |
| `tests/unit/test_models.py` | 1-1491 | Full test suite - follow patterns for new tests |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/__init__.py` | 1-54 | Current export pattern - will need to add new exports |
| `src/teams_phone/models/auth.py` | 10-21 | StrEnum pattern (AuthStatusType) |
| `.taskmaster/tasks/task_009.md` | 44-120 | Implementation notes from 9.1 and 9.2 with key decisions |

### Code Patterns to Follow

```python
# Simple model without aliases from src/teams_phone/models/cache.py:8-25
class CacheMetadata(BaseModel):
    """Metadata about CSV cache files.

    Attributes:
        last_updated: UTC datetime when the oldest cache file was modified.
        locations_count: Number of location records in the cache.
        policies_count: Number of policy records in the cache.
    """

    model_config = ConfigDict(extra="forbid")

    last_updated: datetime | None
    locations_count: int
    policies_count: int
```

```python
# OData alias pattern from src/teams_phone/models/number.py:125
odata_type: Optional[str] = Field(alias="@odata.type", default=None)
```

---

## Dependencies

### Internal Modules
- `pydantic.BaseModel` - Base class for all models
- `pydantic.ConfigDict` - Model configuration
- `pydantic.Field` - Field definitions with aliases
- `typing.Generic` - For generic type parameter in GraphListResponse
- `typing.TypeVar` - For defining type variable T
- `typing.Optional` - For optional fields

### External Packages
- `pydantic>=2.0.0` - Already in pyproject.toml dependencies

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_models.py` (extend existing file - follow existing pattern)

### Test Patterns in Use
```python
# Simple model test pattern from tests/unit/test_models.py
@pytest.mark.unit
def test_create_from_dict(self) -> None:
    """Model can be created from dict input."""
    policy = Policy.model_validate({
        "policy_type": "TeamsCallingPolicy",
        "policy_name": "Global",
        "policy_id": "Global",
        "description": "Default calling policy",
        "is_global": True,
    })
    assert policy.policy_type == "TeamsCallingPolicy"
```

```python
# Extra field rejection pattern from tests/unit/test_models.py
@pytest.mark.unit
def test_extra_field_rejected(self) -> None:
    """Model rejects unknown fields."""
    with pytest.raises(ValidationError) as exc_info:
        Policy(
            policy_type="TeamsCallingPolicy",
            policy_name="Global",
            policy_id="Global",
            description=None,
            is_global=True,
            extra_field="should fail",  # type: ignore[call-arg]
        )
    assert "extra_forbidden" in str(exc_info.value)
```

```python
# Generic type test pattern
@pytest.mark.unit
def test_graph_list_response_with_user_configuration(self) -> None:
    """GraphListResponse can wrap UserConfiguration items."""
    response = GraphListResponse[UserConfiguration].model_validate({
        "@odata.context": "https://graph.microsoft.com/beta/$metadata#...",
        "@odata.count": 2,
        "@odata.nextLink": "https://graph.microsoft.com/beta/...?$skiptoken=...",
        "value": [
            {
                "id": "user-1",
                "userPrincipalName": "user1@contoso.com",
                "tenantId": "tenant-123",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": True,
            }
        ],
    })
    assert len(response.value) == 1
    assert response.value[0].user_principal_name == "user1@contoso.com"
```

### Fixtures Available
- None specific - create sample data in test methods

### Test Utilities
- `tests/conftest.py` - pytest marker configuration (use `@pytest.mark.unit`)
- `pydantic.ValidationError` - For testing validation failures

### Required Test Cases (from task details)
1. Test Policy model with CSV-like dict input
2. Test EmergencyLocation model with all fields
3. Test GraphListResponse[UserConfiguration] parsing with pagination links
4. Test GraphListResponse[NumberAssignment] parsing
5. Test GraphErrorResponse parsing with nested error details
6. Test that extra fields cause ValidationError in all models
7. Test empty value list in GraphListResponse

---

## Risks and Blockers

### Technical Risks
- [ ] **Generic Pydantic models**: Pydantic v2 supports `Generic[T]` but requires specific syntax. Verify with `GraphListResponse[UserConfiguration].model_validate(...)` works correctly.
- [ ] **Self-referential GraphErrorDetail**: Forward reference `list["GraphErrorDetail"] | None` should work but test thoroughly.
- [ ] **OData field aliasing**: Multiple `@odata.*` fields need consistent alias handling.

### Dependency Risks
- None - subtasks 9.1 and 9.2 are complete.

### Open Questions
- None identified - CSV column names are defined in the PowerShell export script.

---

## Implementation Recommendations

### Suggested Approach
1. Create `policy.py` with Policy model (simplest - no aliases, no nesting)
2. Create `location.py` with EmergencyLocation model (same pattern as Policy)
3. Create `api_responses.py` with GraphErrorDetail, GraphErrorResponse, then GraphListResponse[T]
4. Update `__init__.py` exports
5. Add unit tests for each model

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/models/policy.py` | Policy model for CSV data |
| Create | `src/teams_phone/models/location.py` | EmergencyLocation model for CSV data |
| Create | `src/teams_phone/models/api_responses.py` | GraphListResponse[T], GraphErrorDetail, GraphErrorResponse |
| Modify | `src/teams_phone/models/__init__.py` | Export new models |
| Modify | `tests/unit/test_models.py` | Add tests for new models |

### Quick Wins
- Start with `Policy` model - simplest with no aliases or nesting
- Copy pattern from `CacheMetadata` for simple ConfigDict usage

---

## Cheat Sheet

### Key Imports
```python
# For policy.py and location.py
from typing import Optional
from pydantic import BaseModel, ConfigDict

# For api_responses.py
from typing import Generic, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, Field

T = TypeVar("T")
```

### CSV Column Names (from PowerShell script)

**policies.csv columns (scripts/Export-TeamsPhoneData.ps1:275-281):**
- `policyType` -> `policy_type: str`
- `policyName` -> `policy_name: str`
- `policyId` -> `policy_id: str`
- `description` -> `description: str | None`
- `isGlobal` -> `is_global: bool`

**locations.csv columns (scripts/Export-TeamsPhoneData.ps1:214-225):**
- `locationId` -> `location_id: str`
- `civicAddressId` -> `civic_address_id: str`
- `description` -> `description: str`
- `companyName` -> `company_name: str | None`
- `address` -> `address: str | None`
- `city` -> `city: str | None`
- `stateOrProvince` -> `state_or_province: str | None`
- `postalCode` -> `postal_code: str | None`
- `countryOrRegion` -> `country_or_region: str | None`
- `isDefault` -> `is_default: bool`

### Models to Define

```python
# policy.py
class Policy(BaseModel):
    """Voice policy from CSV cache.

    Represents a Teams voice policy exported via PowerShell.
    Used for validation and display since Graph API doesn't expose policy endpoints.

    Attributes:
        policy_type: Type of policy (e.g., "TeamsCallingPolicy", "TenantDialPlan").
        policy_name: Human-readable policy name.
        policy_id: Unique identifier for the policy.
        description: Optional policy description.
        is_global: Whether this is the global (default) policy.
    """

    model_config = ConfigDict(extra="forbid")

    policy_type: str
    policy_name: str
    policy_id: str
    description: str | None
    is_global: bool
```

```python
# location.py
class EmergencyLocation(BaseModel):
    """Emergency location from CSV cache.

    Represents an emergency calling location exported via PowerShell.
    Used for validation and display since Graph API doesn't expose location endpoints.

    Attributes:
        location_id: Unique identifier for the location (GUID).
        civic_address_id: Associated civic address ID (GUID).
        description: Location description/name.
        company_name: Company name at this location.
        address: Street address.
        city: City name.
        state_or_province: State or province.
        postal_code: Postal/ZIP code.
        country_or_region: Country or region code.
        is_default: Whether this is the default location for the civic address.
    """

    model_config = ConfigDict(extra="forbid")

    location_id: str
    civic_address_id: str
    description: str
    company_name: str | None
    address: str | None
    city: str | None
    state_or_province: str | None
    postal_code: str | None
    country_or_region: str | None
    is_default: bool
```

```python
# api_responses.py
from typing import Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field

T = TypeVar("T")


class GraphErrorDetail(BaseModel):
    """Nested error details from Graph API error responses.

    Attributes:
        code: Error code string.
        message: Human-readable error message.
        target: The target of the error (optional).
        details: Nested error details (optional, self-referential).
    """

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    code: str
    message: str
    target: str | None = None
    details: list["GraphErrorDetail"] | None = None


class GraphErrorResponse(BaseModel):
    """Graph API error response wrapper.

    Attributes:
        error: The error detail object.
    """

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    error: GraphErrorDetail


class GraphListResponse(BaseModel, Generic[T]):
    """Generic wrapper for paginated Graph API list responses.

    Type Parameters:
        T: The type of items in the value list.

    Attributes:
        odata_context: OData context URL.
        odata_count: Total count of items (if requested with $count).
        odata_next_link: URL for the next page of results.
        value: List of items of type T.
    """

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    odata_context: str | None = Field(alias="@odata.context", default=None)
    odata_count: int | None = Field(alias="@odata.count", default=None)
    odata_next_link: str | None = Field(alias="@odata.nextLink", default=None)
    value: list[T]
```

### Export Updates for __init__.py

```python
# Add imports
from teams_phone.models.api_responses import (
    GraphErrorDetail,
    GraphErrorResponse,
    GraphListResponse,
)
from teams_phone.models.location import EmergencyLocation
from teams_phone.models.policy import Policy

# Add to __all__
__all__ = [
    # ... existing exports ...
    # API response models
    "GraphErrorDetail",
    "GraphErrorResponse",
    "GraphListResponse",
    # Location models
    "EmergencyLocation",
    # Policy models
    "Policy",
]
```

---

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