# Task Recon: Implement NumberAssignment and AsyncOperation models

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

---

## Task Summary

Create `src/teams_phone/models/number.py` with strict Pydantic models for phone number assignments and async operation polling responses from the Graph API. This includes:
- Four enums: `NumberType`, `ActivationState`, `AssignmentStatus`, `AssignmentCategory`, `OperationStatus`
- `NumberAssignment` model for phone number inventory data
- `OperationNumber` and `AsyncOperation` models for polling async Graph API operations

### 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 phone number-related models (NumberAssignment, AsyncOperation) that are critical for the core CLI operations: listing numbers, assigning/unassigning numbers, and polling for async operation completion. These models follow the patterns established in subtask 9.1 (UserConfiguration).

### Dependencies
- Depends on: Subtask 9.1 (Implement UserConfiguration models) - **DONE**

### Acceptance Criteria
1. Create `NumberType` enum with values: `directRouting`, `callingPlan`, `operatorConnect`
2. Create `ActivationState` enum with values: `activated`, `assignmentPending`, `assignmentFailed`, `updatePending`, `updateFailed`
3. Create `AssignmentStatus` enum with values: `unassigned`, `userAssigned`, `conferenceAssigned`, `voiceApplicationAssigned`
4. Create `AssignmentCategory` enum with values: `primary`, `private`, `alternate`
5. Create `NumberAssignment` model with all fields from Graph API contract
6. Create `OperationStatus` enum with values: `notStarted`, `running`, `succeeded`, `failed`, `skipped`, `unknownFutureValue`
7. Create `OperationNumber` model with `resource_location` and `status` fields
8. Create `AsyncOperation` model with `id`, `created_date_time`, `status`, `numbers` fields
9. All models use `ConfigDict(extra="forbid", populate_by_name=True)`
10. Update `__init__.py` to export all new models
11. Unit tests covering valid parsing, extra field rejection, alias mapping, and enum validation

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 9.1 | Implement UserConfiguration models | **Pattern established:** Use `(str, Enum)` for StrEnum, `ConfigDict(extra="forbid", populate_by_name=True)` for all models. Tests use camelCase aliases directly due to mypy strict mode limitation with `populate_by_name`. Use `model_validate()` for snake_case dict input. |

### Reusable Patterns from Dependencies
- **StrEnum pattern**: `class NumberType(str, Enum):` - see `models/user.py:14-29` (AccountType)
- **ConfigDict with alias**: `ConfigDict(extra="forbid", populate_by_name=True)` - see `models/user.py:40,56,74,100`
- **Field alias pattern**: `field_name: Type = Field(alias="camelCase")` - see `models/user.py:42-43`
- **Nested model with alias**: `policy_assignment: PolicyAssignmentDetail = Field(alias="policyAssignment")` - see `models/user.py:77`
- **Optional field with default**: `display_name: Optional[str] = Field(alias="displayName", default=None)` - see `models/user.py:105`
- **List with default**: `telephone_numbers: list[TelephoneNumber] = Field(alias="telephoneNumbers", default=[])` - see `models/user.py:109-111`

### Warnings and Gotchas
- **mypy strict mode + aliases**: When using `populate_by_name=True`, mypy doesn't recognize snake_case constructor args. Either use camelCase aliases in tests or use `model_validate()` with dicts.
- **Nested model validation**: When passing dicts for nested models, Pydantic automatically validates them. Use `model_validate()` for dict-based construction in tests.
- **Test assertion with aliases**: Error messages from Pydantic use the alias (camelCase), not the field name. E.g., "accounttype" not "account_type" in validation errors.
- **datetime parsing**: Graph API returns ISO 8601 strings - Pydantic handles this automatically.

### Open Items Inherited
- [x] From Task 9.1: `display_name` made optional in UserConfiguration (decision made)
- [ ] Subtask 9.3: Implement Policy, EmergencyLocation, GraphListResponse models (next up after 9.2)

---

## Architecture Context

### Relevant Design Decisions
- **Pydantic strict mode with extra='forbid'**: Defined in `.taskmaster/docs/architecture/12-coding-standards.md:30-41` - all Graph API responses must be validated through Pydantic models that reject unknown fields.
- **Alias mapping**: Graph API uses camelCase, Python code uses snake_case. Use `Field(alias="camelCase")` and `populate_by_name=True`.
- **Async operation polling**: Graph assign/unassign operations return 202 and require polling. The `AsyncOperation` model captures the polling response. See `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md:226-276`.

### 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, TelephoneNumber, etc. (existing - from 9.1)
└── number.py            # NEW: NumberAssignment, AsyncOperation, enums
```

### Conventions to Follow
- Model files named after the domain entity (e.g., `number.py` for phone number models)
- All models inherit from `BaseModel`
- Use `ConfigDict(extra="forbid", populate_by_name=True)` for strict validation with alias support
- Module docstrings describe the purpose
- Class docstrings list attributes with types and descriptions
- Enum docstrings describe each member value

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/user.py` | 14-29 | StrEnum pattern (AccountType) - follow exactly for NumberType, ActivationState, etc. |
| `src/teams_phone/models/user.py` | 32-44 | Model with ConfigDict and Field aliases (TelephoneNumber) - pattern for NumberAssignment |
| `src/teams_phone/models/user.py` | 80-118 | Model with many fields, nested lists, optional datetimes (UserConfiguration) - pattern for NumberAssignment |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 29-50 | NumberAssignment response schema from Graph API |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 242-254 | AsyncOperation response schema from Graph API |
| `.taskmaster/docs/architecture/4-data-models.md` | 22-44 | NumberAssignment data model specification |
| `.taskmaster/docs/architecture/4-data-models.md` | 99-112 | AsyncOperation data model specification |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/__init__.py` | 1-35 | Export pattern for models - will need to add new exports |
| `tests/unit/test_models.py` | 431-461 | Enum tests (AccountType) - follow pattern for new enums |
| `tests/unit/test_models.py` | 466-508 | Model with alias tests (TelephoneNumber) - follow pattern |
| `tests/unit/test_models.py` | 633-839 | Complex model tests (UserConfiguration) - follow pattern for NumberAssignment |
| `.taskmaster/tasks/task_009.md` | 44-80 | Implementation notes from 9.1 with key decisions and gotchas |

### Code Patterns to Follow

```python
# StrEnum pattern from src/teams_phone/models/user.py:14-29
class AccountType(str, Enum):
    """User account type in Microsoft Teams.

    Attributes:
        USER: Standard user account.
        RESOURCE_ACCOUNT: Resource account (e.g., auto attendant, call queue).
        ...
    """

    USER = "user"
    RESOURCE_ACCOUNT = "resourceAccount"
    GUEST = "guest"
    SFB_ON_PREM_USER = "sfbOnPremUser"
    UNKNOWN = "unknown"
```

```python
# Model with many optional fields from src/teams_phone/models/user.py:80-118
class UserConfiguration(BaseModel):
    """Teams user configuration from Microsoft Graph API.

    Attributes:
        id: User's Entra object ID (GUID).
        ...
    """

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

    id: str
    user_principal_name: str = Field(alias="userPrincipalName")
    display_name: Optional[str] = Field(alias="displayName", default=None)
    feature_types: list[str] = Field(alias="featureTypes", default=[])
    created_date_time: Optional[datetime] = Field(alias="createdDateTime", default=None)
```

---

## Dependencies

### Internal Modules
- `pydantic.BaseModel` - Base class for all models
- `pydantic.ConfigDict` - Model configuration
- `pydantic.Field` - Field definitions with aliases
- `datetime` - For datetime fields (createdDateTime)
- `enum.Enum` - For creating string enums
- `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
# Enum value test pattern from tests/unit/test_models.py:434-436
@pytest.mark.unit
def test_user_value(self) -> None:
    """User account type has correct value."""
    assert AccountType.USER.value == "user"
```

```python
# Enum is string test pattern from tests/unit/test_models.py:458-461
@pytest.mark.unit
def test_is_string_enum(self) -> None:
    """AccountType is a string enum."""
    assert isinstance(AccountType.USER, str)
    assert isinstance(AccountType.RESOURCE_ACCOUNT, str)
```

```python
# Model from camelCase alias pattern from tests/unit/test_models.py:480-487
@pytest.mark.unit
def test_create_from_camel_case(self) -> None:
    """TelephoneNumber can be created with camelCase aliases."""
    phone = TelephoneNumber(
        telephoneNumber="+13235533696",
        assignmentCategory="primary",
    )
    assert phone.telephone_number == "+13235533696"
```

```python
# Extra field rejection pattern from tests/unit/test_models.py:489-498
@pytest.mark.unit
def test_extra_field_rejected(self) -> None:
    """TelephoneNumber rejects unknown fields."""
    with pytest.raises(ValidationError) as exc_info:
        TelephoneNumber(
            telephoneNumber="+13235533696",
            assignmentCategory="primary",
            extra_field="should fail",  # type: ignore[call-arg]
        )
    assert "extra_forbidden" in str(exc_info.value)
```

```python
# Full API response test pattern from tests/unit/test_models.py:700-736
@pytest.mark.unit
def test_create_from_graph_api_response(self) -> None:
    """UserConfiguration can be created from Graph API JSON response."""
    api_response = {
        "id": "12345678-1234-5678-1234-567812345678",
        "userPrincipalName": "user@contoso.com",
        ...
    }
    user = UserConfiguration.model_validate(api_response)
    assert user.id == "12345678-1234-5678-1234-567812345678"
```

### Fixtures Available
- None specific - create sample JSON fixtures based on Graph API docs 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 valid NumberAssignment parsing with all fields populated
2. Test NumberAssignment with null optional fields
3. Test AsyncOperation parsing for each OperationStatus value
4. Test that extra fields cause ValidationError
5. Test enum validation for NumberType, ActivationState, AssignmentStatus, AssignmentCategory, OperationStatus
6. Test alias mapping for camelCase -> snake_case

---

## Risks and Blockers

### Technical Risks
- [ ] **`@odata.type` alias**: The `odata_type` field in AsyncOperation has an unusual alias `@odata.type` with `@` character - verify Pydantic handles this correctly with `Field(alias="@odata.type")`
- [ ] **Many enum values**: Five enums with many values - thorough testing required for each

### Dependency Risks
- None - subtask 9.1 is complete

### Open Questions
- None identified - the Graph API contracts document provides complete schema details

---

## Implementation Recommendations

### Suggested Approach
1. Create the enums first (NumberType, ActivationState, AssignmentStatus, AssignmentCategory, OperationStatus) - establishes patterns
2. Create simple model (OperationNumber) - has enum dependency
3. Create AsyncOperation model - depends on OperationNumber and OperationStatus
4. Create NumberAssignment model - depends on NumberType, ActivationState, AssignmentStatus, AssignmentCategory
5. Update `__init__.py` exports
6. Add unit tests for each enum and model

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/models/number.py` | Main file for number-related Pydantic models |
| 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 `NumberType` enum - fastest to implement and test
- Copy/adapt enum test class structure from `TestAccountTypeEnum`

---

## Cheat Sheet

### Key Imports
```python
from datetime import datetime
from enum import Enum
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field
```

### Enums to Define
```python
class NumberType(str, Enum):
    """Phone number type in Teams Phone."""
    DIRECT_ROUTING = "directRouting"
    CALLING_PLAN = "callingPlan"
    OPERATOR_CONNECT = "operatorConnect"

class ActivationState(str, Enum):
    """Phone number activation state."""
    ACTIVATED = "activated"
    ASSIGNMENT_PENDING = "assignmentPending"
    ASSIGNMENT_FAILED = "assignmentFailed"
    UPDATE_PENDING = "updatePending"
    UPDATE_FAILED = "updateFailed"

class AssignmentStatus(str, Enum):
    """Phone number assignment status."""
    UNASSIGNED = "unassigned"
    USER_ASSIGNED = "userAssigned"
    CONFERENCE_ASSIGNED = "conferenceAssigned"
    VOICE_APPLICATION_ASSIGNED = "voiceApplicationAssigned"

class AssignmentCategory(str, Enum):
    """Phone number assignment category."""
    PRIMARY = "primary"
    PRIVATE = "private"
    ALTERNATE = "alternate"

class OperationStatus(str, Enum):
    """Async operation status."""
    NOT_STARTED = "notStarted"
    RUNNING = "running"
    SUCCEEDED = "succeeded"
    FAILED = "failed"
    SKIPPED = "skipped"
    UNKNOWN_FUTURE_VALUE = "unknownFutureValue"
```

### Models to Define
```python
class OperationNumber(BaseModel):
    """Status of a phone number in an async operation."""
    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    resource_location: str = Field(alias="resourceLocation")
    status: OperationStatus

class AsyncOperation(BaseModel):
    """Async operation polling response from Graph API."""
    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    odata_type: Optional[str] = Field(alias="@odata.type", default=None)
    id: str
    created_date_time: datetime = Field(alias="createdDateTime")
    status: OperationStatus
    numbers: list[OperationNumber] = Field(default=[])

class NumberAssignment(BaseModel):
    """Phone number assignment from Graph API."""
    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    id: str
    telephone_number: str = Field(alias="telephoneNumber")
    operator_id: Optional[str] = Field(alias="operatorId", default=None)
    number_type: NumberType = Field(alias="numberType")
    activation_state: ActivationState = Field(alias="activationState")
    capabilities: list[str]
    location_id: Optional[str] = Field(alias="locationId", default=None)
    civic_address_id: Optional[str] = Field(alias="civicAddressId", default=None)
    network_site_id: Optional[str] = Field(alias="networkSiteId", default=None)
    assignment_target_id: Optional[str] = Field(alias="assignmentTargetId", default=None)
    assignment_category: Optional[AssignmentCategory] = Field(alias="assignmentCategory", default=None)
    assignment_status: AssignmentStatus = Field(alias="assignmentStatus")
    port_in_status: Optional[str] = Field(alias="portInStatus", default=None)
    iso_country_code: Optional[str] = Field(alias="isoCountryCode", default=None)
    city: Optional[str] = Field(default=None)
    number_source: Optional[str] = Field(alias="numberSource", default=None)
    supported_customer_actions: list[str] = Field(alias="supportedCustomerActions", default=[])
    reverse_number_lookup_options: list[str] = Field(alias="reverseNumberLookupOptions", default=[])
```

### Graph API Response Schemas (from contracts doc)

**NumberAssignment (from 2-phone-number-operations-verified.md:29-49):**
```json
{
  "id": "string",
  "telephoneNumber": "+12052582895",
  "operatorId": "UUID",
  "numberType": "directRouting|callingPlan|operatorConnect",
  "activationState": "activated|assignmentPending|assignmentFailed|updatePending|updateFailed",
  "capabilities": ["userAssignment", "conferenceAssignment", "voiceApplicationAssignment"],
  "locationId": "string|null",
  "civicAddressId": "string|null",
  "networkSiteId": "string|null",
  "assignmentTargetId": "string|null (user ObjectId)",
  "assignmentCategory": "primary|private|alternate|null",
  "assignmentStatus": "unassigned|userAssigned|conferenceAssigned|voiceApplicationAssigned",
  "portInStatus": "completed|firmOrderCommitmentAccepted|null",
  "isoCountryCode": "US",
  "city": "string|null",
  "numberSource": "online|onPremises",
  "supportedCustomerActions": ["locationUpdate"],
  "reverseNumberLookupOptions": []
}
```

**AsyncOperation (from 2-phone-number-operations-verified.md:242-254):**
```json
{
  "@odata.type": "#microsoft.graph.teamsAdministration.telephoneNumberLongRunningOperation",
  "id": "QXNzaWdubWVudHw2Y2E4Yjc0Ni0...",
  "createdDateTime": "2025-02-03T22:03:26Z",
  "status": "succeeded",
  "numbers": [
    {
      "resourceLocation": "https://graph.microsoft.com/beta/.../numberAssignments/{id}",
      "status": "succeeded"
    }
  ]
}
```

---

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