# Task Recon: Implement unassign command with confirmation prompt and --force flag

**Task ID:** 23.2
**Status:** pending
**Generated:** 2026-01-29T18:45:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add the `unassign` command to `numbers.py` that looks up the phone number to get its current assignment and type, displays a confirmation prompt (unless --force), and calls `NumberService.unassign_number`.

### Parent Task Context

**Parent Task:** Implement Numbers Assign/Unassign CLI Commands (ID: 23)
**Parent Goal:** Add assign and unassign commands to the numbers CLI with progress display and confirmation prompts.
**This Subtask's Role:** Implement the unassign command, which is the second of three subtasks. The assign command (23.1) is already complete, providing patterns to follow. This subtask adds the capability to remove phone number assignments from users.

### Dependencies
- Depends on: 23.1 (assign command - DONE)

### Acceptance Criteria
1. Command signature with parameters: `number` argument, `--force`/`-f`, `--dry-run`, `--no-wait`
2. Number lookup via `NumberService.get_number(number)` to retrieve current assignment
3. Validation that number is currently assigned (assignment_status == userAssigned)
4. Confirmation prompt using `typer.confirm()` pattern from `tenants.py:remove` (lines 241-245)
5. Dry-run mode showing preview of what would be unassigned
6. Call `NumberService.unassign_number(phone_number, number_type, wait=not no_wait)`
7. Success/error output with remediation guidance
8. JSON mode support with structured response

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 23.1 | Implement assign command | Created `AssignServices` pattern and `_run_assign_async` helper for multi-service operations |

### Reusable Patterns from Dependencies
- `AssignServices` container class: Implemented in `src/teams_phone/cli/numbers.py:72-85`
- `_run_assign_async` helper: Implemented in `src/teams_phone/cli/numbers.py:88-121`
- `_format_operation_status`: Implemented in `src/teams_phone/cli/numbers.py:427-436`
- `_operation_to_json_dict`: Implemented in `src/teams_phone/cli/numbers.py:439-458`

### Warnings and Gotchas
- **From Task 23.1**: `_run_assign_async` returns `(result, formatter)` tuple - when coroutine returns a tuple, unpack like: `(a, b), _ = _run_assign_async(...)`
- **From Task 23.1**: Phone numbers in Rich console output may contain ANSI escape codes even with --no-color
- **From Task 23.1**: Import UserService from `teams_phone.services.user_service` directly to avoid circular imports
- **From Task 23.1**: Use `EmergencyLocation.location_id` for ID field (not `.id`)

### Open Items Inherited
- [ ] Subtask 23.3: Add progress display for long-running operations (affects unassign command)

---

## Architecture Context

### Relevant Design Decisions
- Services abstract Graph API + CSV cache access (repository pattern)
- Graph assign/unassign operations return 202 and require polling
- Exponential backoff retry (3 retries) for Graph API throttling (429, 503)
- Pydantic strict mode for fail-fast on API contract changes

### Module Structure
```
src/teams_phone/
├── cli/numbers.py      # Where unassign command will be added
├── services/number_service.py  # NumberService.unassign_number() already implemented
├── models/number.py    # NumberAssignment, AssignmentStatus, NumberType
└── exceptions.py       # ValidationError for already-unassigned numbers
```

### Conventions to Follow
- Use `@numbers_app.command()` decorator for new commands
- CLI commands catch `TeamsPhoneError` and format via `formatter.error()`
- Exit codes: 0=success, 4=NotFound, 5=Validation
- Support `--json` output via `cli_ctx.json_output`

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/numbers.py` | 461-642 | **assign command implementation** - Pattern to follow for unassign |
| `src/teams_phone/cli/numbers.py` | 72-121 | `AssignServices` class and `_run_assign_async` helper to reuse |
| `src/teams_phone/cli/tenants.py` | 241-245 | **Confirmation prompt pattern** - `typer.confirm()` usage |
| `src/teams_phone/services/number_service.py` | 349-399 | `unassign_number` method signature and behavior |
| `src/teams_phone/services/number_service.py` | 116-150 | `get_number` method for looking up number details |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/number.py` | 46-59 | `AssignmentStatus` enum - check `USER_ASSIGNED` for validation |
| `src/teams_phone/cli/numbers.py` | 124-143 | Helper functions for formatting status and number type |
| `src/teams_phone/cli/numbers.py` | 427-458 | `_format_operation_status` and `_operation_to_json_dict` |
| `tests/unit/test_cli_numbers.py` | 705-1157 | Tests for assign command - pattern for unassign tests |

### Code Patterns to Follow

**Confirmation prompt pattern (from tenants.py:241-245):**
```python
# Confirm removal unless --force is used
if not force:
    confirm = typer.confirm(f"Are you sure you want to remove tenant '{name}'?")
    if not confirm:
        formatter.info("Removal cancelled")
        raise typer.Exit(0)
```

**Command signature pattern (from assign command):**
```python
@numbers_app.command("assign")
def assign_number(
    ctx: typer.Context,
    user: str = typer.Argument(..., help="User identifier (UPN, display name, or object ID)."),
    number: str = typer.Argument(..., help="Phone number to assign (E.164 format or partial)."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Preview assignment without making changes."),
    no_wait: bool = typer.Option(False, "--no-wait", help="Return immediately without waiting for operation to complete."),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
) -> None:
```

**Async operation helper pattern:**
```python
async def _execute_unassign(services: AssignServices) -> AsyncOperation:
    return await services.number_service.unassign_number(
        phone_number=number,
        number_type=number_info.number_type,
        wait=not no_wait,
    )

result, _ = _run_assign_async(ctx, _execute_unassign)
```

---

## Dependencies

### Internal Modules
- `teams_phone.cli.context.get_context` - Get CLIContext from typer context
- `teams_phone.models.AssignmentStatus` - Check if number is assigned (USER_ASSIGNED)
- `teams_phone.models.NumberAssignment` - Number details from get_number
- `teams_phone.models.AsyncOperation` - Result from unassign_number
- `teams_phone.models.OperationStatus` - Check operation result
- `teams_phone.exceptions.TeamsPhoneError, ValidationError` - Error handling

### External Packages
- `typer` - CLI framework, `typer.confirm()` for confirmation prompt
- Already installed, no new dependencies needed

### New Dependencies Needed
None required

---

## Testing Guidance

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

### Test Patterns in Use
```python
# Example test structure (tests/unit/test_cli_numbers.py:973-1013)
@pytest.mark.unit
def test_assign_with_confirmation_cancelled(self) -> None:
    """assign cancelled by confirmation prompt exits gracefully."""
    mock_user = _make_user()
    mock_location = _make_location()

    with (
        patch("teams_phone.cli.numbers.CacheManager"),
        patch("teams_phone.cli.numbers.AuthService"),
        patch("teams_phone.cli.numbers.GraphClient") as mock_gc_class,
        patch("teams_phone.cli.numbers.UserService") as mock_us_class,
        patch("teams_phone.cli.numbers.NumberService"),
        patch("teams_phone.cli.numbers.LocationService") as mock_ls_class,
    ):
        # ... setup mocks ...
        result = runner.invoke(
            app,
            ["--no-color", "numbers", "assign", ...],
            input="n\n",  # Decline confirmation
        )
        assert result.exit_code == 0
        assert "cancelled" in result.stdout
```

### Fixtures Available
- `_make_number()` - Create NumberAssignment for testing (lines 34-58)
- `_make_user()` - Create UserConfiguration for testing (lines 660-676)
- `_make_operation()` - Create AsyncOperation for testing (lines 692-702)

### Test Utilities
- `tests/conftest.py` - Pytest markers (unit, integration, contract)
- `runner = CliRunner()` - Typer test runner
- `from typer.testing import CliRunner`

### Tests to Write
Per test strategy from task details:
1. test_unassign_shows_in_help - Command appears in help
2. test_unassign_requires_number_argument - Missing argument error
3. test_unassign_with_confirmation_prompt - Mock typer.confirm
4. test_unassign_with_force_skips_confirmation - --force flag
5. test_unassign_on_already_unassigned_number_shows_error - Validation error
6. test_unassign_dry_run_preview_output - Preview without changes
7. test_unassign_no_wait_returns_immediately - --no-wait mode
8. test_unassign_json_output_format - JSON mode output structure
9. test_unassign_success_output - Standard success message
10. test_unassign_failed_operation_exits_with_error - Handle failed status

---

## Risks and Blockers

### Technical Risks
- [x] **None significant** - All patterns established by assign command (23.1)

### Dependency Risks
- [x] **None** - NumberService.unassign_number is fully implemented and tested

### Open Questions
- [ ] Should the confirmation prompt show the assigned user's display name? The task says "current assignee info" but NumberAssignment only has `assignment_target_id` (object ID, not user details). **Recommendation:** Show the object ID since fetching user details would require an extra API call. The assign command doesn't resolve user details for confirmation either.

---

## Implementation Recommendations

### Suggested Approach
1. Add `unassign` command decorator and signature after the `assign` command (~line 642)
2. First async block: Call `NumberService.get_number(number)` to get current assignment
3. Validate: Check `number_info.assignment_status == AssignmentStatus.USER_ASSIGNED`
4. If not assigned, raise `ValidationError` with remediation
5. Handle `--dry-run`: Show preview and exit
6. Handle confirmation: Unless `--force` or `--json`, prompt user
7. Execute unassignment: Call `NumberService.unassign_number()`
8. Display result: Success/failure message or JSON output

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/cli/numbers.py` | Add unassign command (~100 lines) |
| Modify | `tests/unit/test_cli_numbers.py` | Add unassign command tests (~200 lines) |

### Quick Wins
- Start with the command signature - it's straightforward from the task spec
- Copy the confirmation pattern directly from tenants.py:remove
- Reuse `_run_async` for the get_number lookup (simpler than _run_assign_async)
- Reuse `_format_operation_status` and output patterns from assign command

---

## Cheat Sheet

### Key Imports
```python
# Already imported in numbers.py - no new imports needed
from teams_phone.cli.context import get_context
from teams_phone.exceptions import TeamsPhoneError, ValidationError
from teams_phone.models import AssignmentStatus, AsyncOperation, NumberType, OperationStatus
from teams_phone.services.number_service import NumberService
import typer
```

### Key Functions to Use
- `NumberService.get_number(phone_number)` - Returns NumberAssignment
- `NumberService.unassign_number(phone_number, number_type, wait=True)` - Returns AsyncOperation
- `typer.confirm(message)` - Returns bool, raises Abort on Ctrl+C
- `_run_async(ctx, coro_factory)` - For simple single-service operations
- `_format_number_type(number_type)` - Human-readable number type
- `_format_operation_status(status)` - Human-readable operation status

### Common Patterns
- Confirmation prompt: See `src/teams_phone/cli/tenants.py:241-245`
- Validation error: `raise ValidationError("message", remediation="fix")`
- JSON output check: `if cli_ctx.json_output:`
- Success message: `formatter.success(f"Unassigned {number}")`
- Error with exit: `formatter.error(msg, remediation=...); raise typer.Exit(exit_code)`

### JSON Output Structure (for unassign)
```python
{
    "phone_number": "+14255551234",
    "operation_id": "op-123",
    "status": "succeeded",
    "previous_assignee": "user-object-id"  # From NumberAssignment.assignment_target_id
}
```

---

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