# Write Operations Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `save`, `create`, and `delete` write operations to Cendry's sync and async contexts.

**Architecture:** Methods on `Cendry` / `AsyncCendry` with a shared `_validate_required_fields` helper on `_BaseCendry`. Serialization uses existing `to_dict(instance, by_alias=True)`. A new `DocumentAlreadyExistsError` exception wraps Firestore's `Conflict`.

**Tech Stack:** Python 3.13+, google-cloud-firestore, pytest, pytest-bdd, anyio

**Spec:** `docs/superpowers/specs/2026-03-21-write-operations-design.md`

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `src/cendry/exceptions.py` | Modify | Add `DocumentAlreadyExistsError` |
| `src/cendry/__init__.py` | Modify | Export new exception |
| `src/cendry/context.py` | Modify | Add `_validate_required_fields`, `save`, `create`, `delete` |
| `tests/test_context.py` | Modify | Add unit tests for all write operations |
| `tests/features/write_operations.feature` | Create | BDD scenarios for write operations |
| `tests/test_bdd_write_operations.py` | Create | BDD step definitions for write operations |

---

### Task 1: DocumentAlreadyExistsError exception

**Files:**
- Modify: `src/cendry/exceptions.py`
- Modify: `src/cendry/__init__.py`
- Modify: `tests/test_exceptions.py`
- Modify: `tests/test_public_api.py`

- [ ] **Step 1: Write the failing test**

Add to `tests/test_exceptions.py`:

```python
def test_document_already_exists_error():
    from cendry import DocumentAlreadyExistsError, CendryError

    error = DocumentAlreadyExistsError("cities", "SF")
    assert isinstance(error, CendryError)
    assert error.collection == "cities"
    assert error.document_id == "SF"
    assert "SF" in str(error)
    assert "cities" in str(error)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_exceptions.py::test_document_already_exists_error -v`
Expected: FAIL with `ImportError` (symbol doesn't exist yet)

- [ ] **Step 3: Implement the exception**

Add to `src/cendry/exceptions.py`:

```python
class DocumentAlreadyExistsError(CendryError):
    """Raised when creating a document that already exists."""

    def __init__(self, collection: str, document_id: str) -> None:
        self.collection = collection
        self.document_id = document_id
        super().__init__(f"Document {document_id!r} already exists in {collection!r}")
```

Add to `src/cendry/__init__.py`:
- Import: `from .exceptions import CendryError, DocumentAlreadyExistsError, DocumentNotFoundError`
- Add `"DocumentAlreadyExistsError"` to `__all__` (alphabetical order, after `"Desc"`)

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_exceptions.py::test_document_already_exists_error -v`
Expected: PASS

- [ ] **Step 5: Update public API test if needed**

Check `tests/test_public_api.py` — if it asserts on `__all__` contents, add `DocumentAlreadyExistsError` to the expected list.

- [ ] **Step 6: Run full test suite**

Run: `uv run pytest -q`
Expected: All tests pass

- [ ] **Step 7: Commit**

```bash
git add src/cendry/exceptions.py src/cendry/__init__.py tests/test_exceptions.py tests/test_public_api.py
git commit -m "feat: add DocumentAlreadyExistsError exception"
```

---

### Task 2: `_validate_required_fields` helper

**Files:**
- Modify: `src/cendry/context.py`
- Modify: `tests/test_context.py`

- [ ] **Step 1: Write the failing test**

Add to `tests/test_context.py`:

```python
def test_validate_required_fields_raises_on_none(mock_firestore_client: MagicMock):
    city = City(
        name=None,  # type: ignore[arg-type]
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Required fields are None: name"):
        ctx._validate_required_fields(city)


def test_validate_required_fields_passes_with_defaults(mock_firestore_client: MagicMock):
    city = City(
        name="SF",
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    ctx = Cendry(client=mock_firestore_client)
    ctx._validate_required_fields(city)  # should not raise


def test_validate_required_fields_ignores_optional(mock_firestore_client: MagicMock):
    # nickname has default=None, so None is fine
    city = City(
        name="SF",
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
        nickname=None,
    )
    ctx = Cendry(client=mock_firestore_client)
    ctx._validate_required_fields(city)  # should not raise
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py::test_validate_required_fields_raises_on_none tests/test_context.py::test_validate_required_fields_passes_with_defaults tests/test_context.py::test_validate_required_fields_ignores_optional -v`
Expected: FAIL with `AttributeError` (method doesn't exist yet)

- [ ] **Step 3: Implement the helper**

Add to `_BaseCendry` in `src/cendry/context.py`:

```python
def _validate_required_fields(self, instance: Model) -> None:
    """Raise CendryError if any required fields are None."""
    missing = []
    for f in dataclasses.fields(instance):
        if f.name == "id":
            continue
        has_default = (
            f.default is not dataclasses.MISSING
            or f.default_factory is not dataclasses.MISSING
        )
        if not has_default and getattr(instance, f.name) is None:
            missing.append(f.name)
    if missing:
        fields = ", ".join(missing)
        raise CendryError(f"Required fields are None: {fields}")
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py::test_validate_required_fields_raises_on_none tests/test_context.py::test_validate_required_fields_passes_with_defaults tests/test_context.py::test_validate_required_fields_ignores_optional -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add _validate_required_fields helper on _BaseCendry"
```

---

### Task 3: Sync `save` method

**Files:**
- Modify: `src/cendry/context.py`
- Modify: `tests/test_context.py`

- [ ] **Step 1: Write the failing tests**

Add to `tests/test_context.py`. Add `from cendry.serialize import to_dict` to the imports at the top.

```python
# --- save ---


def test_save_with_explicit_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)

    result = ctx.save(city)

    doc_ref = mock_firestore_client.collection.return_value.document
    doc_ref.assert_called_with("SF")
    doc_ref.return_value.set.assert_called_once_with(to_dict(city, by_alias=True))
    assert result == doc_ref.return_value.id


def test_save_with_auto_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "auto-123"
    mock_firestore_client.collection.return_value.document.return_value = doc_ref_mock

    ctx = Cendry(client=mock_firestore_client)
    result = ctx.save(city)

    mock_firestore_client.collection.return_value.document.assert_called_with()
    doc_ref_mock.set.assert_called_once()
    assert city.id == "auto-123"
    assert result == "auto-123"


def test_save_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    neighborhood = Neighborhood(name="Mission", population=60_000)
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "nb-123"
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    parent_doc.collection.return_value.document.return_value = doc_ref_mock

    ctx = Cendry(client=mock_firestore_client)
    result = ctx.save(neighborhood, parent=parent)

    parent_doc.collection.assert_called_with("neighborhoods")
    assert neighborhood.id == "nb-123"
    assert result == "nb-123"


def test_save_validates_required_fields(mock_firestore_client: MagicMock):
    city = City(
        name=None,  # type: ignore[arg-type]
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Required fields are None"):
        ctx.save(city)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py::test_save_with_explicit_id tests/test_context.py::test_save_with_auto_id tests/test_context.py::test_save_with_parent tests/test_context.py::test_save_validates_required_fields -v`
Expected: FAIL with `AttributeError` (`Cendry` has no `save`)

- [ ] **Step 3: Implement `Cendry.save`**

Add new import to `src/cendry/context.py`:

```python
from .serialize import deserialize, to_dict
```

Add to `Cendry` class (after `select_group`):

```python
def save(self, instance: T, *, parent: Model | None = None) -> str:
    """Save (upsert) a document. Returns the document ID.

    Args:
        instance: Model instance to save.
        parent: Parent document for subcollection writes.

    Returns:
        The document ID (auto-generated if instance.id was None).
    """
    self._validate_required_fields(instance)
    col_ref = self._get_collection_ref(type(instance), parent)
    if instance.id is None:
        doc_ref = col_ref.document()
    else:
        doc_ref = col_ref.document(instance.id)
    doc_ref.set(to_dict(instance, by_alias=True))
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py::test_save_with_explicit_id tests/test_context.py::test_save_with_auto_id tests/test_context.py::test_save_with_parent tests/test_context.py::test_save_validates_required_fields -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add Cendry.save() method"
```

---

### Task 4: Sync `create` method

**Files:**
- Modify: `src/cendry/context.py`
- Modify: `tests/test_context.py`

- [ ] **Step 1: Write the failing tests**

Add `DocumentAlreadyExistsError` to the cendry imports at the top of `tests/test_context.py`.

```python
# --- create ---


def test_create_with_explicit_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)

    result = ctx.create(city)

    doc_ref = mock_firestore_client.collection.return_value.document
    doc_ref.assert_called_with("SF")
    doc_ref.return_value.create.assert_called_once_with(to_dict(city, by_alias=True))
    assert result == doc_ref.return_value.id


def test_create_with_auto_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "auto-456"
    mock_firestore_client.collection.return_value.document.return_value = doc_ref_mock

    ctx = Cendry(client=mock_firestore_client)
    result = ctx.create(city)

    mock_firestore_client.collection.return_value.document.assert_called_with()
    doc_ref_mock.create.assert_called_once()
    assert city.id == "auto-456"
    assert result == "auto-456"


def test_create_raises_on_conflict(mock_firestore_client: MagicMock):
    from google.cloud.exceptions import Conflict

    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.create.side_effect = (
        Conflict("already exists")
    )

    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(DocumentAlreadyExistsError) as exc_info:
        ctx.create(city)

    assert exc_info.value.collection == "cities"
    assert exc_info.value.document_id == "SF"
    assert isinstance(exc_info.value.__cause__, Conflict)


def test_create_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    neighborhood = Neighborhood(name="Mission", population=60_000, id="MISSION")
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    sub_doc_ref = parent_doc.collection.return_value.document.return_value

    ctx = Cendry(client=mock_firestore_client)
    result = ctx.create(neighborhood, parent=parent)

    sub_doc_ref.create.assert_called_once()
    assert result == sub_doc_ref.id


def test_create_validates_required_fields(mock_firestore_client: MagicMock):
    city = City(
        name=None,  # type: ignore[arg-type]
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Required fields are None"):
        ctx.create(city)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py::test_create_with_explicit_id tests/test_context.py::test_create_with_auto_id tests/test_context.py::test_create_raises_on_conflict tests/test_context.py::test_create_with_parent tests/test_context.py::test_create_validates_required_fields -v`
Expected: FAIL with `AttributeError` (`Cendry` has no `create`)

- [ ] **Step 3: Implement `Cendry.create`**

Add new import to `src/cendry/context.py`:

```python
from google.cloud.exceptions import Conflict
```

Add to imports from `.exceptions`:

```python
from .exceptions import CendryError, DocumentAlreadyExistsError, DocumentNotFoundError
```

Add to `Cendry` class (after `save`):

```python
def create(self, instance: T, *, parent: Model | None = None) -> str:
    """Create a document. Raises if it already exists. Returns the document ID.

    Args:
        instance: Model instance to create.
        parent: Parent document for subcollection writes.

    Returns:
        The document ID (auto-generated if instance.id was None).

    Raises:
        DocumentAlreadyExistsError: If the document already exists.
    """
    self._validate_required_fields(instance)
    col_ref = self._get_collection_ref(type(instance), parent)
    if instance.id is None:
        doc_ref = col_ref.document()
    else:
        doc_ref = col_ref.document(instance.id)
    try:
        doc_ref.create(to_dict(instance, by_alias=True))
    except Conflict as e:
        raise DocumentAlreadyExistsError(
            type(instance).__collection__, doc_ref.id
        ) from e
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py::test_create_with_explicit_id tests/test_context.py::test_create_with_auto_id tests/test_context.py::test_create_raises_on_conflict tests/test_context.py::test_create_with_parent tests/test_context.py::test_create_validates_required_fields -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add Cendry.create() method"
```

---

### Task 5: Sync `delete` method

**Files:**
- Modify: `src/cendry/context.py`
- Modify: `tests/test_context.py`

- [ ] **Step 1: Write the failing tests**

```python
# --- delete ---


def test_delete_by_instance(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)

    ctx.delete(city)

    doc_ref = mock_firestore_client.collection.return_value.document
    doc_ref.assert_called_with("SF")
    doc_ref.return_value.delete.assert_called_once()


def test_delete_by_instance_no_id_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    ctx = Cendry(client=mock_firestore_client)

    with pytest.raises(CendryError, match="Cannot delete a model instance with id=None"):
        ctx.delete(city)


def test_delete_by_class_and_id(mock_firestore_client: MagicMock):
    ctx = Cendry(client=mock_firestore_client)
    ctx.delete(City, "SF")

    doc_ref = mock_firestore_client.collection.return_value.document
    doc_ref.assert_called_with("SF")
    doc_ref.return_value.delete.assert_called_once()


def test_delete_by_class_silent_when_missing(mock_firestore_client: MagicMock):
    # Firestore delete is idempotent — no error even if doc doesn't exist
    ctx = Cendry(client=mock_firestore_client)
    ctx.delete(City, "NOPE")  # should not raise


def test_delete_by_class_must_exist_raises(mock_firestore_client: MagicMock):
    doc = make_mock_document("NOPE", {}, exists=False)
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError):
        ctx.delete(City, "NOPE", must_exist=True)


def test_delete_by_class_must_exist_passes(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", SF_DATA)
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    ctx.delete(City, "SF", must_exist=True)

    mock_firestore_client.collection.return_value.document.return_value.delete.assert_called_once()


def test_delete_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    sub_doc_ref = parent_doc.collection.return_value.document.return_value

    ctx = Cendry(client=mock_firestore_client)
    ctx.delete(Neighborhood, "MISSION", parent=parent)

    sub_doc_ref.delete.assert_called_once()
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py -k "test_delete" -v`
Expected: FAIL with `AttributeError` (`Cendry` has no `delete`)

- [ ] **Step 3: Implement `Cendry.delete`**

Add `overload` to the typing imports in `src/cendry/context.py`:

```python
from typing import Any, Self, TypeVar, overload
```

Add to `Cendry` class (after `create`):

```python
@overload
def delete(self, instance: Model, *, parent: Model | None = None) -> None: ...
@overload
def delete(
    self,
    model_class: type[T],
    doc_id: str,
    *,
    parent: Model | None = None,
    must_exist: bool = False,
) -> None: ...

def delete(
    self,
    instance_or_class: Model | type[T],
    doc_id: str | None = None,
    *,
    parent: Model | None = None,
    must_exist: bool = False,
) -> None:
    """Delete a document by instance or by class + ID.

    Args:
        instance_or_class: A Model instance, or a Model class.
        doc_id: Document ID (required when passing a class).
        parent: Parent document for subcollection deletes.
        must_exist: If True, raise DocumentNotFoundError when the document doesn't exist.
    """
    if isinstance(instance_or_class, Model):
        if instance_or_class.id is None:
            raise CendryError("Cannot delete a model instance with id=None")
        col_ref = self._get_collection_ref(type(instance_or_class), parent)
        col_ref.document(instance_or_class.id).delete()
    else:
        col_ref = self._get_collection_ref(instance_or_class, parent)
        if must_exist:
            doc = col_ref.document(doc_id).get()
            if not doc.exists:
                raise DocumentNotFoundError(instance_or_class.__collection__, doc_id)
        col_ref.document(doc_id).delete()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py -k "test_delete" -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add Cendry.delete() method"
```

---

### Task 6: Async `save`, `create`, `delete`

**Files:**
- Modify: `src/cendry/context.py`
- Modify: `tests/test_context.py`

- [ ] **Step 1: Write the failing tests**

```python
# --- async save ---


@pytest.mark.anyio
async def test_async_save_with_explicit_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.set = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.save(city)

    mock_firestore_client.collection.return_value.document.assert_called_with("SF")
    assert result == mock_firestore_client.collection.return_value.document.return_value.id


@pytest.mark.anyio
async def test_async_save_with_auto_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "auto-async-123"
    doc_ref_mock.set = AsyncMock()
    mock_firestore_client.collection.return_value.document.return_value = doc_ref_mock

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.save(city)

    assert city.id == "auto-async-123"
    assert result == "auto-async-123"


@pytest.mark.anyio
async def test_async_save_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    neighborhood = Neighborhood(name="Mission", population=60_000)
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "nb-async-123"
    doc_ref_mock.set = AsyncMock()
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    parent_doc.collection.return_value.document.return_value = doc_ref_mock

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.save(neighborhood, parent=parent)

    parent_doc.collection.assert_called_with("neighborhoods")
    assert neighborhood.id == "nb-async-123"
    assert result == "nb-async-123"


@pytest.mark.anyio
async def test_async_save_validates_required_fields(mock_firestore_client: MagicMock):
    city = City(
        name=None,  # type: ignore[arg-type]
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Required fields are None"):
        await ctx.save(city)


# --- async create ---


@pytest.mark.anyio
async def test_async_create_with_explicit_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.create = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.create(city)

    mock_firestore_client.collection.return_value.document.return_value.create.assert_called_once()
    assert result == mock_firestore_client.collection.return_value.document.return_value.id


@pytest.mark.anyio
async def test_async_create_with_auto_id(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "auto-async-456"
    doc_ref_mock.create = AsyncMock()
    mock_firestore_client.collection.return_value.document.return_value = doc_ref_mock

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.create(city)

    mock_firestore_client.collection.return_value.document.assert_called_with()
    assert city.id == "auto-async-456"
    assert result == "auto-async-456"


@pytest.mark.anyio
async def test_async_create_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    neighborhood = Neighborhood(name="Mission", population=60_000, id="MISSION")
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    sub_doc_ref = parent_doc.collection.return_value.document.return_value
    sub_doc_ref.create = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.create(neighborhood, parent=parent)

    sub_doc_ref.create.assert_called_once()
    assert result == sub_doc_ref.id


@pytest.mark.anyio
async def test_async_create_raises_on_conflict(mock_firestore_client: MagicMock):
    from google.cloud.exceptions import Conflict

    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.create = AsyncMock(
        side_effect=Conflict("already exists")
    )

    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(DocumentAlreadyExistsError) as exc_info:
        await ctx.create(city)

    assert isinstance(exc_info.value.__cause__, Conflict)


@pytest.mark.anyio
async def test_async_create_validates_required_fields(mock_firestore_client: MagicMock):
    city = City(
        name=None,  # type: ignore[arg-type]
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Required fields are None"):
        await ctx.create(city)


# --- async delete ---


@pytest.mark.anyio
async def test_async_delete_by_instance(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.delete = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.delete(city)

    mock_firestore_client.collection.return_value.document.return_value.delete.assert_called_once()


@pytest.mark.anyio
async def test_async_delete_by_instance_no_id_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    ctx = AsyncCendry(client=mock_firestore_client)

    with pytest.raises(CendryError, match="Cannot delete a model instance with id=None"):
        await ctx.delete(city)


@pytest.mark.anyio
async def test_async_delete_by_class_and_id(mock_firestore_client: MagicMock):
    mock_firestore_client.collection.return_value.document.return_value.delete = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.delete(City, "SF")

    mock_firestore_client.collection.return_value.document.assert_called_with("SF")
    mock_firestore_client.collection.return_value.document.return_value.delete.assert_called_once()


@pytest.mark.anyio
async def test_async_delete_by_class_must_exist_raises(mock_firestore_client: MagicMock):
    doc = make_mock_document("NOPE", {}, exists=False)
    mock_firestore_client.collection.return_value.document.return_value.get = AsyncMock(
        return_value=doc,
    )
    mock_firestore_client.collection.return_value.document.return_value.delete = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError):
        await ctx.delete(City, "NOPE", must_exist=True)


@pytest.mark.anyio
async def test_async_delete_by_class_must_exist_passes(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", SF_DATA)
    mock_firestore_client.collection.return_value.document.return_value.get = AsyncMock(
        return_value=doc,
    )
    mock_firestore_client.collection.return_value.document.return_value.delete = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.delete(City, "SF", must_exist=True)

    mock_firestore_client.collection.return_value.document.return_value.delete.assert_called_once()


@pytest.mark.anyio
async def test_async_delete_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    sub_doc_ref = parent_doc.collection.return_value.document.return_value
    sub_doc_ref.delete = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.delete(Neighborhood, "MISSION", parent=parent)

    sub_doc_ref.delete.assert_called_once()
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py -k "test_async_save or test_async_create or test_async_delete" -v`
Expected: FAIL with `AttributeError`

- [ ] **Step 3: Implement async methods on `AsyncCendry`**

Add to `AsyncCendry` class (after `select_group`):

```python
async def save(self, instance: T, *, parent: Model | None = None) -> str:
    """Save (upsert) a document. Returns the document ID."""
    self._validate_required_fields(instance)
    col_ref = self._get_collection_ref(type(instance), parent)
    if instance.id is None:
        doc_ref = col_ref.document()
    else:
        doc_ref = col_ref.document(instance.id)
    await doc_ref.set(to_dict(instance, by_alias=True))
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id

async def create(self, instance: T, *, parent: Model | None = None) -> str:
    """Create a document. Raises if it already exists. Returns the document ID."""
    self._validate_required_fields(instance)
    col_ref = self._get_collection_ref(type(instance), parent)
    if instance.id is None:
        doc_ref = col_ref.document()
    else:
        doc_ref = col_ref.document(instance.id)
    try:
        await doc_ref.create(to_dict(instance, by_alias=True))
    except Conflict as e:
        raise DocumentAlreadyExistsError(
            type(instance).__collection__, doc_ref.id
        ) from e
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id

@overload
async def delete(self, instance: Model, *, parent: Model | None = None) -> None: ...
@overload
async def delete(
    self,
    model_class: type[T],
    doc_id: str,
    *,
    parent: Model | None = None,
    must_exist: bool = False,
) -> None: ...

async def delete(
    self,
    instance_or_class: Model | type[T],
    doc_id: str | None = None,
    *,
    parent: Model | None = None,
    must_exist: bool = False,
) -> None:
    """Delete a document by instance or by class + ID."""
    if isinstance(instance_or_class, Model):
        if instance_or_class.id is None:
            raise CendryError("Cannot delete a model instance with id=None")
        col_ref = self._get_collection_ref(type(instance_or_class), parent)
        await col_ref.document(instance_or_class.id).delete()
    else:
        col_ref = self._get_collection_ref(instance_or_class, parent)
        if must_exist:
            doc = await col_ref.document(doc_id).get()
            if not doc.exists:
                raise DocumentNotFoundError(instance_or_class.__collection__, doc_id)
        await col_ref.document(doc_id).delete()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py -k "test_async_save or test_async_create or test_async_delete" -v`
Expected: PASS

- [ ] **Step 5: Run full test suite**

Run: `uv run pytest -q`
Expected: All tests pass

- [ ] **Step 6: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add async save(), create(), delete() to AsyncCendry"
```

---

### Task 7: BDD feature file and step definitions

**Files:**
- Modify: `tests/conftest.py` (move `SF_DATA` here)
- Modify: `tests/test_context.py` (import `SF_DATA` from conftest)
- Create: `tests/features/write_operations.feature`
- Create: `tests/test_bdd_write_operations.py`

- [ ] **Step 0: Extract `SF_DATA` to conftest**

Move the `SF_DATA` dict from `tests/test_context.py` to `tests/conftest.py` so all test files can share it. Update `tests/test_context.py` to import it: `from tests.conftest import City, Mayor, Neighborhood, make_mock_document, SF_DATA` (add `SF_DATA` to the existing import).

- [ ] **Step 1: Write the BDD feature file**

Create `tests/features/write_operations.feature`:

```gherkin
Feature: Write operations
    As a developer
    I want to save, create, and delete Firestore documents through the ODM
    So that I can persist typed model instances

    Scenario: Save a document with an explicit ID
        Given a City instance with id "SF"
        When I save the instance
        Then the document is written to Firestore
        And the returned ID is "SF"

    Scenario: Save a document with auto-generated ID
        Given a City instance without an id
        When I save the instance
        Then the instance id is set to the generated value
        And the returned ID matches the generated value

    Scenario: Create a document successfully
        Given a City instance with id "SF"
        When I create the instance
        Then the document is created in Firestore
        And the returned ID is "SF"

    Scenario: Create a duplicate document raises error
        Given a City instance with id "SF"
        And the document already exists in Firestore
        When I create the instance
        Then a DocumentAlreadyExistsError is raised

    Scenario: Delete a document by instance
        Given a City instance with id "SF"
        When I delete the instance
        Then the document is deleted from Firestore

    Scenario: Delete a document with id None raises error
        Given a City instance without an id
        When I delete the instance
        Then a CendryError is raised with message "Cannot delete a model instance with id=None"

    Scenario: Delete by class and ID with must_exist on missing doc
        Given a Firestore collection without document "NOPE"
        When I delete City with id "NOPE" and must_exist is true
        Then a DocumentNotFoundError is raised
```

- [ ] **Step 2: Write the BDD step definitions**

Create `tests/test_bdd_write_operations.py`:

```python
from unittest.mock import MagicMock

from google.cloud.exceptions import Conflict
from pytest_bdd import given, parsers, scenario, then, when

from cendry import (
    Cendry,
    CendryError,
    DocumentAlreadyExistsError,
    DocumentNotFoundError,
)
from tests.conftest import SF_DATA, City, make_mock_document

FEATURES = "features"


@scenario(f"{FEATURES}/write_operations.feature", "Save a document with an explicit ID")
def test_save_explicit_id():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Save a document with auto-generated ID")
def test_save_auto_id():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Create a document successfully")
def test_create_success():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Create a duplicate document raises error")
def test_create_duplicate():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Delete a document by instance")
def test_delete_by_instance():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Delete a document with id None raises error")
def test_delete_no_id():
    pass


@scenario(
    f"{FEATURES}/write_operations.feature",
    "Delete by class and ID with must_exist on missing doc",
)
def test_delete_must_exist():
    pass


@given(
    parsers.parse('a City instance with id "{doc_id}"'),
    target_fixture="ctx_and_instance",
)
def city_with_id(doc_id: str):
    client = MagicMock()
    instance = City(**SF_DATA, id=doc_id)
    return Cendry(client=client), instance, client


@given("a City instance without an id", target_fixture="ctx_and_instance")
def city_without_id():
    client = MagicMock()
    doc_ref_mock = MagicMock()
    doc_ref_mock.id = "auto-generated-id"
    client.collection.return_value.document.return_value = doc_ref_mock
    instance = City(**SF_DATA)
    return Cendry(client=client), instance, client


@given("the document already exists in Firestore")
def document_exists(ctx_and_instance):
    _, _, client = ctx_and_instance
    client.collection.return_value.document.return_value.create.side_effect = Conflict(
        "already exists"
    )


@given(
    parsers.parse('a Firestore collection without document "{doc_id}"'),
    target_fixture="ctx_and_instance",
)
def collection_without_doc(doc_id: str):
    client = MagicMock()
    doc = make_mock_document(doc_id, {}, exists=False)
    client.collection.return_value.document.return_value.get.return_value = doc
    return Cendry(client=client), None, client


@when("I save the instance", target_fixture="result")
def save_instance(ctx_and_instance):
    ctx, instance, _ = ctx_and_instance
    return ctx.save(instance)


@when("I create the instance", target_fixture="result")
def create_instance(ctx_and_instance):
    ctx, instance, _ = ctx_and_instance
    try:
        return ctx.create(instance)
    except DocumentAlreadyExistsError as e:
        return e


@when("I delete the instance", target_fixture="result")
def delete_instance(ctx_and_instance):
    ctx, instance, _ = ctx_and_instance
    try:
        ctx.delete(instance)
        return None
    except CendryError as e:
        return e


@when(
    parsers.parse('I delete City with id "{doc_id}" and must_exist is true'),
    target_fixture="result",
)
def delete_by_class_must_exist(ctx_and_instance, doc_id: str):
    ctx, _, _ = ctx_and_instance
    try:
        ctx.delete(City, doc_id, must_exist=True)
        return None
    except DocumentNotFoundError as e:
        return e


@then("the document is written to Firestore")
def check_set_called(ctx_and_instance):
    _, _, client = ctx_and_instance
    client.collection.return_value.document.return_value.set.assert_called_once()


@then(parsers.parse('the returned ID is "{expected_id}"'))
def check_returned_id(result, expected_id: str):
    assert result == expected_id or (hasattr(result, "id") and result.id == expected_id)


@then("the instance id is set to the generated value")
def check_auto_id_set(ctx_and_instance):
    _, instance, _ = ctx_and_instance
    assert instance.id == "auto-generated-id"


@then("the returned ID matches the generated value")
def check_returned_auto_id(result):
    assert result == "auto-generated-id"


@then("the document is created in Firestore")
def check_create_called(ctx_and_instance):
    _, _, client = ctx_and_instance
    client.collection.return_value.document.return_value.create.assert_called_once()


@then("a DocumentAlreadyExistsError is raised")
def check_already_exists(result):
    assert isinstance(result, DocumentAlreadyExistsError)


@then("the document is deleted from Firestore")
def check_delete_called(ctx_and_instance):
    _, _, client = ctx_and_instance
    client.collection.return_value.document.return_value.delete.assert_called_once()


@then(parsers.parse('a CendryError is raised with message "{message}"'))
def check_cendry_error(result, message: str):
    assert isinstance(result, CendryError)
    assert message in str(result)


@then("a DocumentNotFoundError is raised")
def check_not_found(result):
    assert isinstance(result, DocumentNotFoundError)
```

- [ ] **Step 3: Run BDD tests**

Run: `uv run pytest tests/test_bdd_write_operations.py -v`
Expected: PASS (all scenarios green — implementation already done in Tasks 3-6)

- [ ] **Step 4: Run full test suite + coverage**

Run: `uv run coverage run -m pytest && uv run coverage report --show-missing`
Expected: All tests pass, 100% coverage

- [ ] **Step 5: Commit**

```bash
git add tests/features/write_operations.feature tests/test_bdd_write_operations.py
git commit -m "test: add BDD scenarios for write operations"
```

---

### Task 8: Type checking and linting

**Files:** All modified files

- [ ] **Step 1: Run ruff**

Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/`
Expected: No issues. If there are, fix them.

- [ ] **Step 2: Run mypy**

Run: `uv run mypy src/cendry/ tests/`
Expected: No errors. If overload signatures need `type: ignore` comments, add with specific error codes.

- [ ] **Step 3: Run ty**

Run: `uv run ty check src/cendry/ tests/`
Expected: No errors.

- [ ] **Step 4: Fix any issues and commit**

```bash
git add -u
git commit -m "fix: resolve type-checking and lint issues for write operations"
```

(Skip this commit if no fixes were needed.)

---

### Task 9: Final verification

- [ ] **Step 1: Run full test suite with coverage**

Run: `uv run coverage run -m pytest && uv run coverage report --show-missing`
Expected: All tests pass, 100% coverage

- [ ] **Step 2: Run all type checkers**

Run: `uv run mypy src/cendry/ tests/ && uv run ty check src/cendry/ tests/`
Expected: Clean

- [ ] **Step 3: Run linters**

Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/`
Expected: Clean
