# Task Recon: Implement assign_number method with E911 location enforcement

**Task ID:** 21.1
**Status:** pending
**Generated:** 2026-01-29
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add the `assign_number` async method to `NumberService` that validates E911 location requirements for Calling Plan numbers and constructs the Graph API request body. The method should handle the 202 Accepted response and optionally poll for completion.

### Parent Task Context

**Parent Task:** Implement Number Assign/Unassign Operations (ID: 21)
**Parent Goal:** Add assign and unassign methods to NumberService with E911 location enforcement and async polling. This enables the CLI to assign phone numbers to users with proper regulatory compliance (E911 emergency location requirements for Calling Plan numbers).
**This Subtask's Role:** Implement the `assign_number` method with full E911 validation logic, request body construction, and integration with the existing polling infrastructure. This is the core assignment functionality that enforces regulatory requirements and enables number-to-user assignment operations.

### Dependencies
- Depends on: 15 (LocationService - DONE), 20 (Async Operation Polling - DONE)

### Acceptance Criteria
1. Validate that Calling Plan numbers (`NumberType.CALLING_PLAN`) require a `location_id` - raise `ValidationError` if missing
2. Use `format_for_assign()` from utils.py to normalize phone number to digits-only format
3. Build request body with `telephoneNumber`, `numberType` (enum value), `userId`, and optional `locationId`
4. Make POST request to `/admin/teams/telephoneNumberManagement/numberAssignments/assignNumber` endpoint
5. Handle 202 Accepted response by extracting `operation_id` from Location header using `_extract_operation_id`
6. If `wait=True`, call `poll_operation(operation_id, timeout)` and return the completed `AsyncOperation`
7. If `wait=False`, return `AsyncOperation` with `id` and `status='notStarted'`

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 15 | LocationService | Boolean conversion pattern for CSV. LocationService.validate_location() can lookup locations by ID or description. |
| 20.1 | poll_operation | Uses `graph_client` (public), not `_graph_client`. Terminal states: succeeded, failed, skipped. 2-3 second polling interval with jitter. |
| 20.2 | _extract_operation_id | Uses regex pattern `r"operations\('([^']+)'\)"`. Raises ValidationError for invalid format. Already implemented and tested. |

### Reusable Patterns from Dependencies
- **poll_operation**: Already implemented in `number_service.py:197-250` - use for wait=True mode
- **_extract_operation_id**: Already implemented in `number_service.py:252-281` - use to parse Location header
- **format_for_assign**: In `utils.py:44-64` - normalizes phone to digits-only format
- **ValidationError**: Import pattern in `number_service.py:15` alongside NotFoundError

### Warnings and Gotchas
- **Location header uses OData format**: `operations('id')` with quotes and parentheses
- **Polling endpoint uses REST format**: `operations/{id}` - different from Location header
- **Use `graph_client`**: Public attribute, NOT `_graph_client` (private)
- **POST returns 202**: NOT 200 - need to check for 202 status code and access Location header
- **Phone number format**: Graph API requires digits-only (no + prefix) for assignNumber endpoint

### Open Items Inherited
- GraphClient.post() currently returns `dict[str, Any]` - need to access Location header for 202 responses
- May need to extend GraphClient to expose response headers, or use raw httpx response

---

## Architecture Context

### Relevant Design Decisions
- **E911 Requirement**: Per `2-phone-number-operations-verified.md:126-135`, Calling Plan numbers MUST have locationId. Graph API docs say optional but backend enforces it for regulatory compliance.
- **Async Operations**: Graph API returns 202 with Location header for assign/unassign - must poll for completion.
- **Services raise domain exceptions**: All errors should be ValidationError, NotFoundError, etc. - never raw Python exceptions.

### Module Structure
```
src/teams_phone/services/number_service.py  <- Add assign_number here
src/teams_phone/utils.py                    <- format_for_assign()
src/teams_phone/exceptions.py               <- ValidationError
src/teams_phone/models/number.py            <- NumberType, AsyncOperation
```

### Conventions to Follow
- Use `from __future__ import annotations` (already present)
- All public async methods have comprehensive docstrings with Args/Returns/Raises
- Keyword-only arguments with `*` separator for optional params
- Use type hints on all parameters and return values

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/number_service.py` | 1-282 | Current NumberService with poll_operation and _extract_operation_id |
| `src/teams_phone/utils.py` | 44-64 | format_for_assign() function to normalize phone numbers |
| `src/teams_phone/models/number.py` | 14-25, 76-93, 110-129 | NumberType enum, OperationStatus enum, AsyncOperation model |
| `src/teams_phone/infrastructure/graph_client.py` | 397-424 | GraphClient.post() method - returns dict, need header access |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 79-141 | assignNumber endpoint contract with E911 requirements |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `tests/unit/test_number_service.py` | 895-1079 | Test patterns for poll_operation tests |
| `src/teams_phone/exceptions.py` | 85-95 | ValidationError class definition |
| `.taskmaster/tasks/task_020.md` | 49-130 | Implementation notes from Task 20 |

### Code Patterns to Follow

```python
# poll_operation method pattern (number_service.py:197-250)
async def poll_operation(
    self,
    operation_id: str,
    *,
    timeout: int = 60,
    on_progress: Callable[[AsyncOperation], None] | None = None,
) -> AsyncOperation:
    """Poll operation until complete or timeout."""
    ...
```

```python
# _extract_operation_id pattern (number_service.py:252-281)
def _extract_operation_id(self, location_header: str) -> str:
    """Extract operation ID from Location header."""
    pattern = r"operations\('([^']+)'\)"
    match = re.search(pattern, location_header)
    if not match:
        raise ValidationError(...)
    return match.group(1)
```

```python
# format_for_assign usage (utils.py:44-64)
from teams_phone.utils import format_for_assign
normalized = format_for_assign(phone_number)  # Returns digits only, validates format
```

---

## Dependencies

### Internal Modules
- `teams_phone.utils.format_for_assign` - Phone number normalization
- `teams_phone.exceptions.ValidationError` - For E911 validation errors
- `teams_phone.models.NumberType` - Enum for number types (CALLING_PLAN, DIRECT_ROUTING, OPERATOR_CONNECT)
- `teams_phone.models.AsyncOperation` - Return type
- `teams_phone.models.OperationStatus` - For creating AsyncOperation with notStarted status

### External Packages
- None required - all dependencies are internal

### New Dependencies Needed
- **CRITICAL**: GraphClient needs a way to return response headers for 202 responses. Current `post()` method only returns JSON body.

---

## Testing Guidance

### Test File Location
`tests/unit/test_number_service.py` - Add new test class `TestNumberServiceAssignNumber`

### Test Patterns in Use

```python
# Mock async iterator pattern (from test_number_service.py:120-131)
async def mock_paginated(*args: Any, **kwargs: Any) -> AsyncIterator[dict[str, Any]]:
    return
    yield  # pragma: no cover - makes this an async generator

# Mock AsyncMock for graph_client.get (from test_number_service.py:904-907)
from unittest.mock import AsyncMock
mock_graph_client.get = AsyncMock(return_value=make_operation_dict(status="succeeded"))
```

### Fixtures Available
- `mock_graph_client` - MagicMock for GraphClient (existing fixture at line 93-96)

### Test Utilities
- `make_operation_dict()` - Helper to create async operation dict (lines 22-52)

### Required Test Cases (from test strategy)
1. E911 validation raises ValidationError for CallingPlan without location_id with correct remediation message
2. CallingPlan with location_id succeeds
3. DirectRouting/OperatorConnect work without location_id
4. Phone number formatting via format_for_assign
5. Request body construction includes all required fields
6. 202 response triggers operation_id extraction
7. wait=True calls poll_operation
8. wait=False returns immediate AsyncOperation

---

## Risks and Blockers

### Technical Risks
- **CRITICAL**: GraphClient.post() returns dict, not response with headers. Need to access Location header from 202 response.
  - **Mitigation Options**:
    1. Add new `post_async()` method that returns tuple of (body, headers)
    2. Add `post_with_response()` that returns raw httpx.Response
    3. Modify `post()` to check for 202 and return a structured response with headers
  - **Recommendation**: Option 2 (post_with_response) - minimal change, clear semantics

### Dependency Risks
- None - Tasks 15 and 20 are complete

### Open Questions
- [ ] How to access Location header from GraphClient.post() response? Need to extend GraphClient or use raw httpx.

---

## Implementation Recommendations

### Suggested Approach
1. **First**: Extend GraphClient with a method to access response headers for 202 responses (or use `_request` directly)
2. Implement `assign_number` method signature with all parameters
3. Add E911 validation logic first (Calling Plan requires location_id)
4. Build request body with format_for_assign for phone number
5. Make POST request and handle 202 response
6. Extract operation_id from Location header using `_extract_operation_id`
7. If wait=True, call `poll_operation` and return result
8. If wait=False, construct and return AsyncOperation with status='notStarted'
9. Write comprehensive unit tests

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/services/number_service.py` | Add assign_number method |
| Modify | `src/teams_phone/infrastructure/graph_client.py` | Add method to access 202 response headers |
| Modify | `tests/unit/test_number_service.py` | Add TestNumberServiceAssignNumber test class |

### Quick Wins
- Start with E911 validation logic and tests - easy to verify
- Then add request body construction
- Finally handle 202 response and polling integration

---

## Cheat Sheet

### Key Imports
```python
# Already in number_service.py imports
from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import (
    AsyncOperation,
    NumberType,
    OperationStatus,
)
from teams_phone.utils import format_for_assign  # ADD THIS
```

### Method Signature
```python
async def assign_number(
    self,
    user_id: str,
    phone_number: str,
    number_type: NumberType,
    *,
    location_id: str | None = None,
    wait: bool = True,
    timeout: int = 60,
) -> AsyncOperation:
    """Assign a phone number to a user.

    Args:
        user_id: User's Entra object ID (GUID).
        phone_number: Phone number in any format.
        number_type: Type of phone number (DirectRouting, CallingPlan, OperatorConnect).
        location_id: Emergency location ID. Required for Calling Plan numbers.
        wait: If True, poll for operation completion. If False, return immediately.
        timeout: Maximum seconds to wait for completion (default 60).

    Returns:
        AsyncOperation representing the assignment result.

    Raises:
        ValidationError: If Calling Plan number is missing required location_id.
        TimeoutError: If wait=True and operation doesn't complete within timeout.
    """
```

### E911 Validation Pattern
```python
if number_type == NumberType.CALLING_PLAN and not location_id:
    raise ValidationError(
        f"Emergency location required for Calling Plan number {phone_number}",
        remediation="Use --location flag or set default_location in tenant config",
    )
```

### Request Body Construction
```python
body = {
    "telephoneNumber": format_for_assign(phone_number),  # Digits only
    "numberType": number_type.value,  # e.g., "callingPlan"
    "assignmentTargetId": user_id,
}
if location_id:
    body["locationId"] = location_id
```

### Endpoint
```
POST /admin/teams/telephoneNumberManagement/numberAssignments/assignNumber
```

### AsyncOperation Construction (for wait=False)
```python
from datetime import datetime, timezone

return AsyncOperation(
    id=operation_id,
    created_date_time=datetime.now(timezone.utc),
    status=OperationStatus.NOT_STARTED,
    numbers=[],
)
```

### Common Patterns
- `format_for_assign(phone)` - Returns digits-only string
- `_extract_operation_id(location_header)` - Parses operation ID from Location header
- `await poll_operation(op_id, timeout=timeout)` - Polls until completion

---

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