# Cendry Firestore ODM 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:** Build a read-only Firestore ODM with typed models, composable filters, and sync/async context classes.

**Architecture:** Thin wrapper over `google-cloud-firestore`. `Model`/`Map` base classes use `__init_subclass__` and dataclass machinery for typed fields. `Field[T]` descriptors provide both instance value access and class-level filter methods. `Cendry`/`AsyncCendry` translate query calls into Firestore client operations.

**Tech Stack:** Python >= 3.13, google-cloud-firestore, anyio, pytest, pytest-bdd, mypy, ty

---

## File Map

| File | Responsibility |
|------|---------------|
| `pyproject.toml` | Project config, dependencies, tool settings |
| `src/cendry/__init__.py` | Public API re-exports |
| `src/cendry/exceptions.py` | `CendryError`, `DocumentNotFound` |
| `src/cendry/model.py` | `Model`, `Map`, `Field`, `field` |
| `src/cendry/filters.py` | `FieldFilter` re-export, `And`, `Or`, filter base class |
| `src/cendry/query.py` | `Asc`, `Desc`, query-building internals |
| `src/cendry/context.py` | `Cendry`, `AsyncCendry` |
| `tests/conftest.py` | Shared fixtures, mock Firestore client |
| `tests/test_exceptions.py` | Exception hierarchy tests |
| `tests/test_model.py` | Model/Map definition tests |
| `tests/test_filters.py` | Filter creation and composition tests |
| `tests/test_query.py` | Asc/Desc tests |
| `tests/test_context.py` | Context get/find/select/select_group tests |
| `tests/features/model.feature` | BDD scenarios for model definition |
| `tests/features/filters.feature` | BDD scenarios for filters |
| `tests/features/query.feature` | BDD scenarios for queries |
| `tests/features/context.feature` | BDD scenarios for context operations |

---

### Task 1: Project Scaffolding

**Files:**
- Modify: `pyproject.toml`
- Create: `src/cendry/__init__.py`
- Create: `src/cendry/py.typed`
- Create: `tests/__init__.py`
- Create: `tests/conftest.py`
- Delete: `main.py`

- [ ] **Step 1: Update pyproject.toml**

```toml
[project]
name = "cendry"
version = "0.0.1"
description = "A Firestore ODM for Python"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "google-cloud-firestore>=2.19",
    "anyio>=4",
]

[dependency-groups]
dev = [
    "pytest>=8",
    "pytest-bdd>=8",
    "anyio[pytest]",
    "mypy>=1",
    "ty",
    "coverage>=7",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/cendry"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
strict = true
packages = ["cendry"]
mypy_path = "src"

[tool.coverage.run]
source = ["cendry"]

[tool.coverage.report]
fail_under = 100
show_missing = true
```

- [ ] **Step 2: Create src/cendry/__init__.py**

```python
"""Cendry — A Firestore ODM for Python."""
```

- [ ] **Step 3: Create src/cendry/py.typed (empty marker file)**

Empty file — signals PEP 561 type information.

- [ ] **Step 4: Create tests/__init__.py (empty)**

- [ ] **Step 5: Create tests/conftest.py**

```python
"""Shared test configuration."""
```

- [ ] **Step 6: Delete main.py**

```bash
rm main.py
```

- [ ] **Step 7: Install dependencies**

```bash
uv sync
```

- [ ] **Step 8: Verify setup**

```bash
uv run pytest --co -q
uv run mypy src/cendry/
```

Expected: pytest collects 0 tests, mypy reports success.

- [ ] **Step 9: Commit**

```bash
git add -A && git commit -m "chore: scaffold project with dependencies and tooling"
```

---

### Task 2: Exceptions

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

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

```python
# tests/test_exceptions.py
from cendry import CendryError, DocumentNotFound


def test_cendry_error_is_exception():
    assert issubclass(CendryError, Exception)


def test_document_not_found_is_cendry_error():
    assert issubclass(DocumentNotFound, CendryError)


def test_document_not_found_message():
    err = DocumentNotFound("cities", "SF")
    assert "cities" in str(err)
    assert "SF" in str(err)
```

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

```bash
uv run pytest tests/test_exceptions.py -v
```

Expected: ImportError — `CendryError` not found.

- [ ] **Step 3: Implement exceptions**

```python
# src/cendry/exceptions.py
class CendryError(Exception):
    """Base exception for all Cendry errors."""


class DocumentNotFound(CendryError):
    """Raised when a document does not exist."""

    def __init__(self, collection: str, document_id: str) -> None:
        self.collection = collection
        self.document_id = document_id
        super().__init__(
            f"Document '{document_id}' not found in collection '{collection}'"
        )
```

- [ ] **Step 4: Update __init__.py exports**

```python
# src/cendry/__init__.py
"""Cendry — A Firestore ODM for Python."""

from cendry.exceptions import CendryError, DocumentNotFound

__all__ = [
    "CendryError",
    "DocumentNotFound",
]
```

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

```bash
uv run pytest tests/test_exceptions.py -v
```

Expected: 3 passed.

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

```bash
uv run mypy src/cendry/
```

Expected: Success.

- [ ] **Step 7: Commit**

```bash
git add -A && git commit -m "feat: add exception hierarchy (CendryError, DocumentNotFound)"
```

---

### Task 3: Filters — FieldFilter re-export, And, Or

**Files:**
- Create: `src/cendry/filters.py`
- Create: `tests/test_filters.py`
- Modify: `src/cendry/__init__.py`

- [ ] **Step 1: Write failing tests for filter base types**

```python
# tests/test_filters.py
from cendry import FieldFilter, And, Or
from google.cloud.firestore_v1.base_query import FieldFilter as FirestoreFieldFilter


def test_field_filter_is_firestore_field_filter():
    assert FieldFilter is FirestoreFieldFilter


def test_field_filter_creation():
    f = FieldFilter("state", "==", "CA")
    assert f.field_path == "state"
    assert f.op_string == "=="
    assert f.value == "CA"
```

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

```bash
uv run pytest tests/test_filters.py -v
```

Expected: ImportError.

- [ ] **Step 3: Write failing tests for And/Or**

```python
# append to tests/test_filters.py
from cendry.filters import Filter


def test_and_combines_filters():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("population", ">", 1000000)
    result = And(f1, f2)
    assert isinstance(result, Filter)


def test_or_combines_filters():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("country", "==", "Japan")
    result = Or(f1, f2)
    assert isinstance(result, Filter)


def test_and_nested_in_or():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("country", "==", "Japan")
    f3 = FieldFilter("population", ">", 1000000)
    result = Or(f1, And(f2, f3))
    assert isinstance(result, Filter)


def test_and_via_ampersand():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("population", ">", 1000000)
    result = f1 & f2
    assert isinstance(result, And)


def test_or_via_pipe():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("country", "==", "Japan")
    result = f1 | f2
    assert isinstance(result, Or)
```

- [ ] **Step 4: Implement filters**

```python
# src/cendry/filters.py
from __future__ import annotations

from google.cloud.firestore_v1.base_query import FieldFilter


class Filter:
    """Base class for composable filters."""

    def __and__(self, other: Filter | FieldFilter) -> And:
        return And(self, other)

    def __or__(self, other: Filter | FieldFilter) -> Or:
        return Or(self, other)


class And(Filter):
    """Composite AND filter."""

    def __init__(self, *filters: Filter | FieldFilter) -> None:
        if len(filters) < 2:
            raise CendryError("And requires at least 2 filters")
        self.filters = filters


class Or(Filter):
    """Composite OR filter."""

    def __init__(self, *filters: Filter | FieldFilter) -> None:
        if len(filters) < 2:
            raise CendryError("Or requires at least 2 filters")
        self.filters = filters


from cendry.exceptions import CendryError  # noqa: E402
```

Note: `FieldFilter` from Firestore does not inherit `Filter`, so we need to monkey-patch or wrap `&`/`|` support. A cleaner approach: instead of monkey-patching, handle `FieldFilter` instances in `And`/`Or` and accept that `&`/`|` on raw `FieldFilter` won't work — users use `And(f1, f2)` for `FieldFilter`, and `&`/`|` only on field descriptor results (which return `Filter` subclasses). Update the tests accordingly:

```python
# Revise: remove test_and_via_ampersand and test_or_via_pipe for raw FieldFilter
# & and | operators work on Filter subclasses (field descriptor results), not raw FieldFilter
```

- [ ] **Step 5: Update __init__.py exports**

Add `FieldFilter`, `And`, `Or` to `__init__.py` and `__all__`.

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

```bash
uv run pytest tests/test_filters.py -v
```

Expected: All pass.

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

```bash
uv run mypy src/cendry/
```

- [ ] **Step 8: Commit**

```bash
git add -A && git commit -m "feat: add filter types (FieldFilter re-export, And, Or)"
```

---

### Task 4: Model and Map — Field descriptors

**Files:**
- Create: `src/cendry/model.py`
- Create: `tests/test_model.py`
- Modify: `src/cendry/__init__.py`

- [ ] **Step 1: Write failing tests for Map**

```python
# tests/test_model.py
import dataclasses

from cendry import Map, Field


def test_map_is_dataclass():
    class Mayor(Map):
        name: Field[str]
        since: Field[int]

    assert dataclasses.is_dataclass(Mayor)


def test_map_instantiation():
    class Mayor(Map):
        name: Field[str]
        since: Field[int]

    m = Mayor(name="Jane", since=2020)
    assert m.name == "Jane"
    assert m.since == 2020


def test_map_has_no_id():
    class Mayor(Map):
        name: Field[str]

    assert "id" not in {f.name for f in dataclasses.fields(Mayor)}


def test_map_nested_in_map():
    class Address(Map):
        street: Field[str]

    class Person(Map):
        name: Field[str]
        address: Field[Address]

    p = Person(name="Jane", address=Address(street="123 Main"))
    assert p.address.street == "123 Main"
```

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

```bash
uv run pytest tests/test_model.py -v
```

Expected: ImportError.

- [ ] **Step 3: Write failing tests for Model**

```python
# append to tests/test_model.py
import pytest
from cendry import Model, field


def test_model_requires_collection():
    with pytest.raises(TypeError):
        class Bad(Model):
            name: Field[str]


def test_model_is_dataclass():
    class City(Model, collection="cities"):
        name: Field[str]

    assert dataclasses.is_dataclass(City)


def test_model_has_id():
    class City(Model, collection="cities"):
        name: Field[str]

    c = City(name="SF")
    assert c.id is None


def test_model_collection_stored():
    class City(Model, collection="cities"):
        name: Field[str]

    assert City.__collection__ == "cities"


def test_model_with_default():
    class City(Model, collection="cities"):
        name: Field[str]
        nickname: Field[str | None] = field(default=None)

    c = City(name="SF")
    assert c.nickname is None


def test_model_cannot_nest_model():
    class City(Model, collection="cities"):
        name: Field[str]

    with pytest.raises(TypeError, match="cannot nest"):
        class Country(Model, collection="countries"):
            city: Field[City]


def test_map_cannot_nest_model():
    class City(Model, collection="cities"):
        name: Field[str]

    with pytest.raises(TypeError, match="cannot nest"):
        class Info(Map):
            city: Field[City]
```

- [ ] **Step 4: Write failing tests for Field descriptor filter methods**

```python
# append to tests/test_model.py
from cendry.filters import Filter


def test_field_descriptor_eq():
    class City(Model, collection="cities"):
        state: Field[str]

    result = City.state.eq("CA")
    assert isinstance(result, Filter)


def test_field_descriptor_ne():
    class City(Model, collection="cities"):
        state: Field[str]

    result = City.state.ne("CA")
    assert isinstance(result, Filter)


def test_field_descriptor_gt():
    class City(Model, collection="cities"):
        population: Field[int]

    result = City.population.gt(1000000)
    assert isinstance(result, Filter)


def test_field_descriptor_gte():
    class City(Model, collection="cities"):
        population: Field[int]

    result = City.population.gte(1000000)
    assert isinstance(result, Filter)


def test_field_descriptor_lt():
    class City(Model, collection="cities"):
        population: Field[int]

    result = City.population.lt(500)
    assert isinstance(result, Filter)


def test_field_descriptor_lte():
    class City(Model, collection="cities"):
        population: Field[int]

    result = City.population.lte(500)
    assert isinstance(result, Filter)


def test_field_descriptor_array_contains():
    class City(Model, collection="cities"):
        regions: Field[list[str]]

    result = City.regions.array_contains("west_coast")
    assert isinstance(result, Filter)


def test_field_descriptor_array_contains_any():
    class City(Model, collection="cities"):
        regions: Field[list[str]]

    result = City.regions.array_contains_any(["west_coast", "east_coast"])
    assert isinstance(result, Filter)


def test_field_descriptor_is_in():
    class City(Model, collection="cities"):
        country: Field[str]

    result = City.country.is_in(["USA", "Japan"])
    assert isinstance(result, Filter)


def test_field_descriptor_not_in():
    class City(Model, collection="cities"):
        country: Field[str]

    result = City.country.not_in(["China"])
    assert isinstance(result, Filter)


def test_field_descriptor_composition_and():
    class City(Model, collection="cities"):
        state: Field[str]
        population: Field[int]

    result = City.state.ne("CA") & City.population.gt(1000000)
    assert isinstance(result, Filter)


def test_field_descriptor_composition_or():
    class City(Model, collection="cities"):
        state: Field[str]
        country: Field[str]

    result = City.state.eq("CA") | City.country.eq("Japan")
    assert isinstance(result, Filter)
```

- [ ] **Step 5: Implement Field, field, Map, Model**

```python
# src/cendry/model.py
from __future__ import annotations

import dataclasses
from typing import Any, Generic, TypeVar, get_type_hints, get_origin, get_args

from cendry.filters import Filter, And, Or

T = TypeVar("T")


class FieldDescriptor:
    """Class-level descriptor returned when accessing Field on a class.

    Provides filter methods: eq, ne, gt, gte, lt, lte,
    array_contains, array_contains_any, is_in, not_in.
    Supports & (And) and | (Or) composition.
    """

    def __init__(self, field_name: str) -> None:
        self.field_name = field_name

    def _make_filter(self, op: str, value: Any) -> FieldFilterResult:
        return FieldFilterResult(self.field_name, op, value)

    def eq(self, value: Any) -> FieldFilterResult:
        return self._make_filter("==", value)

    def ne(self, value: Any) -> FieldFilterResult:
        return self._make_filter("!=", value)

    def gt(self, value: Any) -> FieldFilterResult:
        return self._make_filter(">", value)

    def gte(self, value: Any) -> FieldFilterResult:
        return self._make_filter(">=", value)

    def lt(self, value: Any) -> FieldFilterResult:
        return self._make_filter("<", value)

    def lte(self, value: Any) -> FieldFilterResult:
        return self._make_filter("<=", value)

    def array_contains(self, value: Any) -> FieldFilterResult:
        return self._make_filter("array-contains", value)

    def array_contains_any(self, value: Any) -> FieldFilterResult:
        return self._make_filter("array-contains-any", value)

    def is_in(self, value: Any) -> FieldFilterResult:
        return self._make_filter("in", value)

    def not_in(self, value: Any) -> FieldFilterResult:
        return self._make_filter("not-in", value)


class FieldFilterResult(Filter):
    """A filter produced by a field descriptor method."""

    def __init__(self, field_name: str, op: str, value: Any) -> None:
        self.field_name = field_name
        self.op = op
        self.value = value


class Field(Generic[T]):
    """Type annotation marker for model fields.

    At class level: returns a FieldDescriptor for building filters.
    At instance level: returns the stored value.
    """
    pass


def field(*, default: Any = dataclasses.MISSING, default_factory: Any = dataclasses.MISSING) -> Any:
    """Configure a model field with defaults."""
    return dataclasses.field(default=default, default_factory=default_factory)


def _validate_no_nested_models(cls: type, hints: dict[str, Any]) -> None:
    """Raise TypeError if any field references a Model subclass."""
    for name, hint in hints.items():
        origin = get_origin(hint)
        args = get_args(hint)
        # Field[SomeType] — check SomeType
        if origin is Field or (args and len(args) == 1):
            inner = args[0] if args else hint
            # Unwrap Optional (X | None)
            inner_origin = get_origin(inner)
            if inner_origin is type(int | str):  # UnionType
                inner_args = get_args(inner)
                for a in inner_args:
                    if isinstance(a, type) and issubclass(a, Model):
                        raise TypeError(
                            f"Field '{name}' on '{cls.__name__}' cannot nest Model "
                            f"'{a.__name__}'. Use Map for embedded data."
                        )
            elif isinstance(inner, type) and issubclass(inner, Model):
                raise TypeError(
                    f"Field '{name}' on '{cls.__name__}' cannot nest Model "
                    f"'{inner.__name__}'. Use Map for embedded data."
                )


class _MapMeta(type):
    """Metaclass for Map that applies dataclass and sets up field descriptors."""

    def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any) -> type:
        cls = super().__new__(mcs, name, bases, namespace)
        if name in ("Map", "Model"):
            return cls
        # Apply dataclass
        cls = dataclasses.dataclass(cls)
        # Set up field descriptors on the class for filter access
        for f in dataclasses.fields(cls):
            setattr(cls, f.name, FieldDescriptor(f.name))
        return cls


class Map(metaclass=_MapMeta):
    """Base class for embedded Firestore maps (nested data)."""
    pass


class Model(Map):
    """Base class for Firestore documents bound to a collection."""

    __collection__: str
    id: str | None = dataclasses.field(default=None, init=True)

    def __init_subclass__(cls, collection: str | None = None, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        if collection is None:
            raise TypeError(
                f"Model '{cls.__name__}' must specify collection: "
                f"class {cls.__name__}(Model, collection='...')"
            )
        cls.__collection__ = collection
```

**Implementation notes for Field[T] resolution:**

The metaclass `__new__` must do these things in order:

1. **Rewrite `__annotations__`** before calling `dataclasses.dataclass()`: iterate over `cls.__annotations__`, and for each `Field[X]`, replace it with `X` so that `dataclasses.dataclass()` sees plain types.
2. **Call `dataclasses.dataclass(cls)`** to apply dataclass behavior with the rewritten annotations.
3. **Call `_validate_no_nested_models(cls, hints)`** to reject `Field[SomeModel]`.
4. **Set `FieldDescriptor` instances** on the class for each dataclass field: `setattr(cls, f.name, FieldDescriptor(f.name))`.
5. **`FieldDescriptor.__get__`** must implement the descriptor protocol: when accessed on a class (`obj is None`), return `self` (the descriptor with filter methods); when accessed on an instance (`obj is not None`), return `obj.__dict__[self.field_name]` (the actual value from the dataclass instance dict).

- [ ] **Step 6: Update __init__.py exports**

Add `Model`, `Map`, `Field`, `field` to `__init__.py` and `__all__`.

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

```bash
uv run pytest tests/test_model.py -v
```

Expected: All pass.

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

```bash
uv run mypy src/cendry/
```

- [ ] **Step 9: Commit**

```bash
git add -A && git commit -m "feat: add Model, Map, Field descriptor with filter methods"
```

---

### Task 5: Query — Asc, Desc

**Files:**
- Create: `src/cendry/query.py`
- Create: `tests/test_query.py`
- Modify: `src/cendry/__init__.py`

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

```python
# tests/test_query.py
from cendry import Asc, Desc, Model, Field
from cendry.model import FieldDescriptor


def test_asc_with_string():
    a = Asc("population")
    assert a.field == "population"
    assert a.direction == "ASCENDING"


def test_desc_with_string():
    d = Desc("population")
    assert d.field == "population"
    assert d.direction == "DESCENDING"


def test_asc_with_field_descriptor():
    class City(Model, collection="cities"):
        population: Field[int]

    a = Asc(City.population)
    assert isinstance(a.field, str)
    assert a.direction == "ASCENDING"


def test_desc_with_field_descriptor():
    class City(Model, collection="cities"):
        population: Field[int]

    d = Desc(City.population)
    assert isinstance(d.field, str)
    assert d.direction == "DESCENDING"
```

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

```bash
uv run pytest tests/test_query.py -v
```

Expected: ImportError.

- [ ] **Step 3: Implement Asc and Desc**

```python
# src/cendry/query.py
from __future__ import annotations

from cendry.model import FieldDescriptor


class _Order:
    """Base class for ordering directives."""

    direction: str

    def __init__(self, field: str | FieldDescriptor) -> None:
        if isinstance(field, FieldDescriptor):
            self.field = field.field_name
        else:
            self.field = field


class Asc(_Order):
    """Ascending order."""
    direction = "ASCENDING"


class Desc(_Order):
    """Descending order."""
    direction = "DESCENDING"
```

- [ ] **Step 4: Update __init__.py exports**

Add `Asc`, `Desc` to `__init__.py` and `__all__`.

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

```bash
uv run pytest tests/test_query.py -v
```

Expected: All pass.

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

```bash
uv run mypy src/cendry/
```

- [ ] **Step 7: Commit**

```bash
git add -A && git commit -m "feat: add Asc, Desc ordering directives"
```

---

### Task 6: Context — Cendry (sync)

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

This task uses a mock Firestore client to test the context without a real Firestore instance. The mock simulates `Client`, `collection`, `document`, `get`, and `where`/`stream` behavior.

- [ ] **Step 1: Set up mock fixtures in conftest.py**

```python
# tests/conftest.py
"""Shared test configuration."""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

import pytest

from cendry import Model, Field, Map, field


class Mayor(Map):
    name: Field[str]
    since: Field[int]


class City(Model, collection="cities"):
    name: Field[str]
    state: Field[str]
    country: Field[str]
    capital: Field[bool]
    population: Field[int]
    regions: Field[list[str]]
    nickname: Field[str | None] = field(default=None)
    mayor: Field[Mayor | None] = field(default=None)


class Neighborhood(Model, collection="neighborhoods"):
    name: Field[str]
    population: Field[int]


def make_mock_document(doc_id: str, data: dict[str, Any], exists: bool = True) -> MagicMock:
    """Create a mock Firestore DocumentSnapshot."""
    doc = MagicMock()
    doc.id = doc_id
    doc.exists = exists
    doc.to_dict.return_value = data if exists else None
    return doc


@pytest.fixture
def mock_firestore_client() -> MagicMock:
    """Create a mock Firestore Client."""
    return MagicMock()
```

- [ ] **Step 2: Write failing tests for get/find**

```python
# tests/test_context.py
from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from cendry import Cendry, DocumentNotFound
from tests.conftest import City, make_mock_document


def test_get_existing_document(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", {
        "name": "San Francisco",
        "state": "CA",
        "country": "USA",
        "capital": False,
        "population": 870000,
        "regions": ["west_coast"],
    })
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    city = ctx.get(City, "SF")

    assert city.id == "SF"
    assert city.name == "San Francisco"
    assert city.state == "CA"
    assert isinstance(city, City)


def test_get_nonexistent_document(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(DocumentNotFound):
        ctx.get(City, "NOPE")


def test_find_existing_document(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", {
        "name": "San Francisco",
        "state": "CA",
        "country": "USA",
        "capital": False,
        "population": 870000,
        "regions": ["west_coast"],
    })
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    city = ctx.find(City, "SF")
    assert city is not None
    assert city.name == "San Francisco"


def test_find_nonexistent_document(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)
    result = ctx.find(City, "NOPE")
    assert result is None
```

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

```bash
uv run pytest tests/test_context.py -v
```

Expected: ImportError — `Cendry` not found.

- [ ] **Step 4: Implement Cendry get/find**

```python
# src/cendry/context.py
from __future__ import annotations

from typing import Any, Iterator, TypeVar

from google.cloud.firestore import Client

from cendry.exceptions import DocumentNotFound
from cendry.model import Model

T = TypeVar("T", bound=Model)


class Cendry:
    """Synchronous Firestore ODM context."""

    def __init__(self, *, client: Client | None = None) -> None:
        self._client = client or Client()

    def _deserialize(self, model_class: type[T], doc_id: str, data: dict[str, Any]) -> T:
        """Convert a Firestore document dict to a model instance."""
        return model_class(id=doc_id, **data)

    def get(self, model_class: type[T], document_id: str, *, parent: Model | None = None) -> T:
        """Get a document by ID. Raises DocumentNotFound if it doesn't exist."""
        col_ref = self._get_collection_ref(model_class, parent)
        doc = col_ref.document(document_id).get()
        if not doc.exists:
            raise DocumentNotFound(model_class.__collection__, document_id)
        return self._deserialize(model_class, doc.id, doc.to_dict())

    def find(self, model_class: type[T], document_id: str, *, parent: Model | None = None) -> T | None:
        """Get a document by ID. Returns None if it doesn't exist."""
        col_ref = self._get_collection_ref(model_class, parent)
        doc = col_ref.document(document_id).get()
        if not doc.exists:
            return None
        return self._deserialize(model_class, doc.id, doc.to_dict())

    def _get_collection_ref(self, model_class: type[T], parent: Model | None = None) -> Any:
        """Get a Firestore collection reference, optionally nested under a parent."""
        if parent is not None:
            if parent.id is None:
                raise CendryError("Parent model must have a non-None id")
            parent_ref = self._client.collection(parent.__collection__).document(parent.id)
            return parent_ref.collection(model_class.__collection__)
        return self._client.collection(model_class.__collection__)


from cendry.exceptions import CendryError  # noqa: E402
```

- [ ] **Step 5: Update __init__.py exports**

Add `Cendry` to `__init__.py` and `__all__`.

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

```bash
uv run pytest tests/test_context.py -v
```

Expected: 4 passed.

- [ ] **Step 7: Write failing tests for select (sync iterator)**

```python
# append to tests/test_context.py
from cendry import FieldFilter


def test_select_with_field_filter(mock_firestore_client: MagicMock):
    docs = [
        make_mock_document("SF", {
            "name": "San Francisco", "state": "CA", "country": "USA",
            "capital": False, "population": 870000, "regions": ["west_coast"],
        }),
        make_mock_document("LA", {
            "name": "Los Angeles", "state": "CA", "country": "USA",
            "capital": False, "population": 3900000, "regions": ["west_coast"],
        }),
    ]
    query_mock = MagicMock()
    query_mock.stream.return_value = iter(docs)
    mock_firestore_client.collection.return_value.where.return_value = query_mock

    ctx = Cendry(client=mock_firestore_client)
    cities = list(ctx.select(City, FieldFilter("state", "==", "CA")))

    assert len(cities) == 2
    assert all(isinstance(c, City) for c in cities)
    assert cities[0].name == "San Francisco"


def test_select_no_filters(mock_firestore_client: MagicMock):
    docs = [
        make_mock_document("SF", {
            "name": "San Francisco", "state": "CA", "country": "USA",
            "capital": False, "population": 870000, "regions": ["west_coast"],
        }),
    ]
    mock_firestore_client.collection.return_value.stream.return_value = iter(docs)

    ctx = Cendry(client=mock_firestore_client)
    cities = list(ctx.select(City))

    assert len(cities) == 1


def test_select_with_limit(mock_firestore_client: MagicMock):
    docs = [
        make_mock_document("SF", {
            "name": "San Francisco", "state": "CA", "country": "USA",
            "capital": False, "population": 870000, "regions": ["west_coast"],
        }),
    ]
    limit_mock = MagicMock()
    limit_mock.stream.return_value = iter(docs)
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock

    ctx = Cendry(client=mock_firestore_client)
    cities = list(ctx.select(City, limit=1))

    assert len(cities) == 1
    mock_firestore_client.collection.return_value.limit.assert_called_once_with(1)


def test_select_with_parent(mock_firestore_client: MagicMock):
    from tests.conftest import Neighborhood

    city = City(id="SF", name="San Francisco", state="CA", country="USA",
                capital=False, population=870000, regions=["west_coast"])
    docs = [
        make_mock_document("MISSION", {"name": "Mission", "population": 60000}),
    ]
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    parent_doc.collection.return_value.stream.return_value = iter(docs)

    ctx = Cendry(client=mock_firestore_client)
    neighborhoods = list(ctx.select(Neighborhood, parent=city))

    assert len(neighborhoods) == 1
    assert neighborhoods[0].name == "Mission"
```

- [ ] **Step 8: Implement select on Cendry**

Add `select` method to `Cendry` class:

```python
def select(
    self,
    model_class: type[T],
    *filters: Any,
    order_by: list[Any] | None = None,
    limit: int | None = None,
    start_at: dict[str, Any] | Model | None = None,
    start_after: dict[str, Any] | Model | None = None,
    end_at: dict[str, Any] | Model | None = None,
    end_before: dict[str, Any] | Model | None = None,
    parent: Model | None = None,
) -> Iterator[T]:
    """Query documents. Returns a lazy iterator."""
    query = self._build_query(
        model_class, filters, order_by=order_by, limit=limit,
        start_at=start_at, start_after=start_after,
        end_at=end_at, end_before=end_before, parent=parent,
    )
    for doc in query.stream():
        yield self._deserialize(model_class, doc.id, doc.to_dict())

def _build_query(
    self,
    model_class: type[T],
    filters: tuple[Any, ...],
    *,
    order_by: list[Any] | None = None,
    limit: int | None = None,
    start_at: dict[str, Any] | Model | None = None,
    start_after: dict[str, Any] | Model | None = None,
    end_at: dict[str, Any] | Model | None = None,
    end_before: dict[str, Any] | Model | None = None,
    parent: Model | None = None,
    collection_group: bool = False,
) -> Any:
    """Build a Firestore query from filters, ordering, and pagination."""
    if collection_group:
        query = self._client.collection_group(model_class.__collection__)
    else:
        query = self._get_collection_ref(model_class, parent)

    for f in filters:
        query = self._apply_filter(query, f)

    if order_by:
        for o in order_by:
            query = query.order_by(o.field, direction=o.direction)

    if limit is not None:
        query = query.limit(limit)

    if start_at is not None:
        query = query.start_at(self._cursor_value(start_at))
    if start_after is not None:
        query = query.start_after(self._cursor_value(start_after))
    if end_at is not None:
        query = query.end_at(self._cursor_value(end_at))
    if end_before is not None:
        query = query.end_before(self._cursor_value(end_before))

    return query

def _apply_filter(self, query: Any, f: Any) -> Any:
    """Apply a single filter or composite filter to a query."""
    from google.cloud.firestore_v1.base_query import FieldFilter as FsFieldFilter
    from cendry.filters import And, Or, Filter
    from cendry.model import FieldFilterResult

    if isinstance(f, FsFieldFilter):
        return query.where(filter=f)
    elif isinstance(f, FieldFilterResult):
        fs_filter = FsFieldFilter(f.field_name, f.op, f.value)
        return query.where(filter=fs_filter)
    elif isinstance(f, And):
        from google.cloud.firestore_v1.base_query import And as FsAnd
        fs_filters = [self._to_firestore_filter(sub) for sub in f.filters]
        return query.where(filter=FsAnd(filters=fs_filters))
    elif isinstance(f, Or):
        from google.cloud.firestore_v1.base_query import Or as FsOr
        fs_filters = [self._to_firestore_filter(sub) for sub in f.filters]
        return query.where(filter=FsOr(filters=fs_filters))
    else:
        raise CendryError(f"Unknown filter type: {type(f)}")

def _to_firestore_filter(self, f: Any) -> Any:
    """Convert a cendry filter to a Firestore filter."""
    from google.cloud.firestore_v1.base_query import FieldFilter as FsFieldFilter
    from google.cloud.firestore_v1.base_query import And as FsAnd, Or as FsOr
    from cendry.filters import And, Or
    from cendry.model import FieldFilterResult

    if isinstance(f, FsFieldFilter):
        return f
    elif isinstance(f, FieldFilterResult):
        return FsFieldFilter(f.field_name, f.op, f.value)
    elif isinstance(f, And):
        return FsAnd(filters=[self._to_firestore_filter(sub) for sub in f.filters])
    elif isinstance(f, Or):
        return FsOr(filters=[self._to_firestore_filter(sub) for sub in f.filters])
    else:
        raise CendryError(f"Unknown filter type: {type(f)}")

def _cursor_value(self, cursor: dict[str, Any] | Model) -> dict[str, Any]:
    """Convert a cursor to a dict for Firestore."""
    if isinstance(cursor, Model):
        import dataclasses
        d = dataclasses.asdict(cursor)
        d.pop("id", None)
        return d
    return cursor
```

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

```bash
uv run pytest tests/test_context.py -v
```

Expected: All pass.

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

```bash
uv run mypy src/cendry/
```

- [ ] **Step 11: Commit**

```bash
git add -A && git commit -m "feat: add Cendry sync context with get, find, select"
```

---

### Task 7: Context — select_group (sync)

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

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

```python
# append to tests/test_context.py
def test_select_group(mock_firestore_client: MagicMock):
    from tests.conftest import Neighborhood

    docs = [
        make_mock_document("MISSION", {"name": "Mission", "population": 60000}),
        make_mock_document("SHIBUYA", {"name": "Shibuya", "population": 230000}),
    ]
    query_mock = MagicMock()
    query_mock.stream.return_value = iter(docs)
    mock_firestore_client.collection_group.return_value = query_mock

    ctx = Cendry(client=mock_firestore_client)
    neighborhoods = list(ctx.select_group(Neighborhood))

    assert len(neighborhoods) == 2
    mock_firestore_client.collection_group.assert_called_once_with("neighborhoods")
```

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

```bash
uv run pytest tests/test_context.py::test_select_group -v
```

Expected: AttributeError — `Cendry` has no `select_group`.

- [ ] **Step 3: Implement select_group**

Add to `Cendry` class:

```python
def select_group(
    self,
    model_class: type[T],
    *filters: Any,
    order_by: list[Any] | None = None,
    limit: int | None = None,
    start_at: dict[str, Any] | Model | None = None,
    start_after: dict[str, Any] | Model | None = None,
    end_at: dict[str, Any] | Model | None = None,
    end_before: dict[str, Any] | Model | None = None,
) -> Iterator[T]:
    """Query across all subcollections with the given collection name."""
    query = self._build_query(
        model_class, filters, order_by=order_by, limit=limit,
        start_at=start_at, start_after=start_after,
        end_at=end_at, end_before=end_before,
        collection_group=True,
    )
    for doc in query.stream():
        yield self._deserialize(model_class, doc.id, doc.to_dict())
```

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

```bash
uv run pytest tests/test_context.py -v
```

Expected: All pass.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: add select_group for collection group queries"
```

---

### Task 8: Context — AsyncCendry

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

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

```python
# append to tests/test_context.py
import pytest
from unittest.mock import AsyncMock
from cendry import AsyncCendry


@pytest.mark.anyio
async def test_async_get_existing(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", {
        "name": "San Francisco", "state": "CA", "country": "USA",
        "capital": False, "population": 870000, "regions": ["west_coast"],
    })
    mock_firestore_client.collection.return_value.document.return_value.get = AsyncMock(return_value=doc)

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

    assert city.id == "SF"
    assert city.name == "San Francisco"


@pytest.mark.anyio
async def test_async_get_not_found(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)

    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFound):
        await ctx.get(City, "NOPE")


@pytest.mark.anyio
async def test_async_find_existing(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", {
        "name": "San Francisco", "state": "CA", "country": "USA",
        "capital": False, "population": 870000, "regions": ["west_coast"],
    })
    mock_firestore_client.collection.return_value.document.return_value.get = AsyncMock(return_value=doc)

    ctx = AsyncCendry(client=mock_firestore_client)
    city = await ctx.find(City, "SF")
    assert city is not None
    assert city.name == "San Francisco"


@pytest.mark.anyio
async def test_async_find_not_found(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)

    ctx = AsyncCendry(client=mock_firestore_client)
    result = await ctx.find(City, "NOPE")
    assert result is None


@pytest.mark.anyio
async def test_async_select(mock_firestore_client: MagicMock):
    docs = [
        make_mock_document("SF", {
            "name": "San Francisco", "state": "CA", "country": "USA",
            "capital": False, "population": 870000, "regions": ["west_coast"],
        }),
    ]

    async def mock_stream():
        for d in docs:
            yield d

    mock_firestore_client.collection.return_value.stream = mock_stream

    ctx = AsyncCendry(client=mock_firestore_client)
    cities = []
    async for city in ctx.select(City):
        cities.append(city)

    assert len(cities) == 1
    assert cities[0].name == "San Francisco"
```

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

```bash
uv run pytest tests/test_context.py -k async -v
```

Expected: ImportError — `AsyncCendry` not found.

- [ ] **Step 3: Implement AsyncCendry**

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

```python
from typing import AsyncIterator
from google.cloud.firestore import AsyncClient


class AsyncCendry:
    """Asynchronous Firestore ODM context. Works with anyio (asyncio + trio)."""

    def __init__(self, *, client: AsyncClient | None = None) -> None:
        self._client = client or AsyncClient()

    def _deserialize(self, model_class: type[T], doc_id: str, data: dict[str, Any]) -> T:
        return model_class(id=doc_id, **data)

    async def get(self, model_class: type[T], document_id: str, *, parent: Model | None = None) -> T:
        col_ref = self._get_collection_ref(model_class, parent)
        doc = await col_ref.document(document_id).get()
        if not doc.exists:
            raise DocumentNotFound(model_class.__collection__, document_id)
        return self._deserialize(model_class, doc.id, doc.to_dict())

    async def find(self, model_class: type[T], document_id: str, *, parent: Model | None = None) -> T | None:
        col_ref = self._get_collection_ref(model_class, parent)
        doc = await col_ref.document(document_id).get()
        if not doc.exists:
            return None
        return self._deserialize(model_class, doc.id, doc.to_dict())

    async def select(
        self,
        model_class: type[T],
        *filters: Any,
        order_by: list[Any] | None = None,
        limit: int | None = None,
        start_at: dict[str, Any] | Model | None = None,
        start_after: dict[str, Any] | Model | None = None,
        end_at: dict[str, Any] | Model | None = None,
        end_before: dict[str, Any] | Model | None = None,
        parent: Model | None = None,
    ) -> AsyncIterator[T]:
        query = self._build_query(
            model_class, filters, order_by=order_by, limit=limit,
            start_at=start_at, start_after=start_after,
            end_at=end_at, end_before=end_before, parent=parent,
        )
        async for doc in query.stream():
            yield self._deserialize(model_class, doc.id, doc.to_dict())

    async def select_group(
        self,
        model_class: type[T],
        *filters: Any,
        order_by: list[Any] | None = None,
        limit: int | None = None,
        start_at: dict[str, Any] | Model | None = None,
        start_after: dict[str, Any] | Model | None = None,
        end_at: dict[str, Any] | Model | None = None,
        end_before: dict[str, Any] | Model | None = None,
    ) -> AsyncIterator[T]:
        query = self._build_query(
            model_class, filters, order_by=order_by, limit=limit,
            start_at=start_at, start_after=start_after,
            end_at=end_at, end_before=end_before,
            collection_group=True,
        )
        async for doc in query.stream():
            yield self._deserialize(model_class, doc.id, doc.to_dict())

    # _get_collection_ref, _build_query, _apply_filter,
    # _to_firestore_filter, _cursor_value are identical to Cendry.
    # Extract these into a _BaseCendry base class that both Cendry
    # and AsyncCendry inherit from. Move these methods there during
    # this step. Only get/find/select/select_group differ (sync vs async).
```

Note: `_build_query`, `_apply_filter`, `_to_firestore_filter`, `_cursor_value`, and `_get_collection_ref` are identical between `Cendry` and `AsyncCendry`. Extract these into a `_BaseCendry` mixin to avoid duplication. Both `Cendry` and `AsyncCendry` inherit from it.

- [ ] **Step 4: Update __init__.py exports**

Add `AsyncCendry` to `__init__.py` and `__all__`.

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

```bash
uv run pytest tests/test_context.py -v
```

Expected: All pass (sync + async).

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

```bash
uv run mypy src/cendry/
```

- [ ] **Step 7: Commit**

```bash
git add -A && git commit -m "feat: add AsyncCendry with async get, find, select, select_group"
```

---

### Task 9: Map Deserialization (nested maps)

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

- [ ] **Step 1: Write failing test for nested map deserialization**

```python
# append to tests/test_context.py
from tests.conftest import Mayor


def test_get_with_nested_map(mock_firestore_client: MagicMock):
    doc = make_mock_document("SF", {
        "name": "San Francisco",
        "state": "CA",
        "country": "USA",
        "capital": False,
        "population": 870000,
        "regions": ["west_coast"],
        "mayor": {"name": "London Breed", "since": 2018},
    })
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    city = ctx.get(City, "SF")

    assert isinstance(city.mayor, Mayor)
    assert city.mayor.name == "London Breed"
    assert city.mayor.since == 2018
```

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

```bash
uv run pytest tests/test_context.py::test_get_with_nested_map -v
```

Expected: Fails — mayor is a dict, not a `Mayor` instance.

- [ ] **Step 3: Update _deserialize to handle nested Maps**

Update `_deserialize` in the base class to recursively convert dicts to `Map` subclass instances based on type hints:

```python
def _deserialize(self, model_class: type[T], doc_id: str, data: dict[str, Any]) -> T:
    """Convert a Firestore document dict to a model instance."""
    import dataclasses
    from typing import get_type_hints, get_origin, get_args

    hints = get_type_hints(model_class)
    converted = {}
    for f in dataclasses.fields(model_class):
        if f.name == "id":
            continue
        value = data.get(f.name)
        hint = hints.get(f.name)
        if value is not None and hint is not None:
            inner = self._unwrap_field_type(hint)
            if isinstance(inner, type) and issubclass(inner, Map) and isinstance(value, dict):
                value = self._deserialize_map(inner, value)
        converted[f.name] = value

    return model_class(id=doc_id, **converted)

def _deserialize_map(self, map_class: type, data: dict[str, Any]) -> Any:
    """Recursively deserialize a Map from a dict."""
    import dataclasses
    from typing import get_type_hints

    hints = get_type_hints(map_class)
    converted = {}
    for f in dataclasses.fields(map_class):
        value = data.get(f.name)
        hint = hints.get(f.name)
        if value is not None and hint is not None:
            inner = self._unwrap_field_type(hint)
            if isinstance(inner, type) and issubclass(inner, Map) and isinstance(value, dict):
                value = self._deserialize_map(inner, value)
        converted[f.name] = value
    return map_class(**converted)

def _unwrap_field_type(self, hint: Any) -> Any:
    """Extract the inner type from Field[T] or T | None."""
    from typing import get_origin, get_args, Union
    origin = get_origin(hint)
    args = get_args(hint)
    # Field[T] -> T
    if origin is Field:
        hint = args[0] if args else hint
        origin = get_origin(hint)
        args = get_args(hint)
    # T | None -> T
    if origin is Union or origin is type(int | None):
        non_none = [a for a in args if a is not type(None)]
        if len(non_none) == 1:
            return non_none[0]
    return hint
```

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

```bash
uv run pytest tests/test_context.py -v
```

Expected: All pass.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: support nested Map deserialization"
```

---

### Task 10: BDD Feature Files

**Files:**
- Create: `tests/features/model.feature`
- Create: `tests/features/filters.feature`
- Create: `tests/features/query.feature`
- Create: `tests/features/context.feature`
- Create: `tests/test_bdd_model.py`
- Create: `tests/test_bdd_filters.py`
- Create: `tests/test_bdd_query.py`
- Create: `tests/test_bdd_context.py`

- [ ] **Step 1: Write model.feature**

```gherkin
# tests/features/model.feature
Feature: Model definition
    As a developer
    I want to define Firestore document models
    So that I can work with typed data

    Scenario: Define a model with a collection
        Given a model class "City" with collection "cities"
        Then the model has collection "cities"
        And the model has an "id" field

    Scenario: Model requires a collection
        When I define a model without a collection
        Then a TypeError is raised

    Scenario: Define an embedded map
        Given a map class "Mayor" with fields "name" and "since"
        Then the map has no "id" field
        And the map is a dataclass

    Scenario: Model cannot nest another model
        Given a model class "City" with collection "cities"
        When I define a model that nests "City"
        Then a TypeError is raised with message containing "cannot nest"
```

- [ ] **Step 2: Write test_bdd_model.py step definitions**

```python
# tests/test_bdd_model.py
import dataclasses

import pytest
from pytest_bdd import scenario, given, when, then, parsers

from cendry import Model, Map, Field

FEATURES = "features"


@scenario(f"{FEATURES}/model.feature", "Define a model with a collection")
def test_define_model():
    pass


@scenario(f"{FEATURES}/model.feature", "Model requires a collection")
def test_model_requires_collection():
    pass


@scenario(f"{FEATURES}/model.feature", "Define an embedded map")
def test_define_map():
    pass


@scenario(f"{FEATURES}/model.feature", "Model cannot nest another model")
def test_model_cannot_nest():
    pass


@given(parsers.parse('a model class "{name}" with collection "{collection}"'), target_fixture="model_class")
def model_class(name, collection):
    return type(name, (Model,), {"__annotations__": {"name": Field[str]}}, collection=collection)


@then(parsers.parse('the model has collection "{collection}"'))
def check_collection(model_class, collection):
    assert model_class.__collection__ == collection


@then('the model has an "id" field')
def check_id_field(model_class):
    field_names = {f.name for f in dataclasses.fields(model_class)}
    assert "id" in field_names


@when("I define a model without a collection", target_fixture="model_error")
def define_model_no_collection():
    with pytest.raises(TypeError) as exc_info:
        class Bad(Model):
            name: Field[str]
    return exc_info


@then("a TypeError is raised")
def check_type_error(model_error):
    assert model_error.type is TypeError


@given(parsers.parse('a map class "{name}" with fields "{f1}" and "{f2}"'), target_fixture="map_class")
def map_class(name, f1, f2):
    return type(name, (Map,), {"__annotations__": {f1: Field[str], f2: Field[int]}})


@then('the map has no "id" field')
def check_no_id(map_class):
    field_names = {f.name for f in dataclasses.fields(map_class)}
    assert "id" not in field_names


@then("the map is a dataclass")
def check_is_dataclass(map_class):
    assert dataclasses.is_dataclass(map_class)


@when(parsers.parse('I define a model that nests "{model_name}"'), target_fixture="nest_error")
def define_nested_model(model_class, model_name):
    with pytest.raises(TypeError, match="cannot nest") as exc_info:
        type("Bad", (Model,), {"__annotations__": {"ref": Field[model_class]}}, collection="bad")
    return exc_info


@then(parsers.parse('a TypeError is raised with message containing "{text}"'))
def check_error_message(nest_error, text):
    assert text in str(nest_error.value)
```

- [ ] **Step 3: Write filters.feature**

```gherkin
# tests/features/filters.feature
Feature: Query filters
    As a developer
    I want to filter Firestore queries
    So that I can retrieve specific documents

    Scenario: Create a field filter
        Given a FieldFilter with field "state", operator "==" and value "CA"
        Then the filter is a valid Firestore FieldFilter

    Scenario: Compose filters with AND
        Given two field filters
        When I combine them with And
        Then the result is a composite filter

    Scenario: Compose filters with OR
        Given two field filters
        When I combine them with Or
        Then the result is a composite filter

    Scenario: Field descriptor produces a filter
        Given a model with a "state" field
        When I call eq("CA") on the field descriptor
        Then the result is a filter with operator "=="
```

- [ ] **Step 4: Write test_bdd_filters.py step definitions**

```python
# tests/test_bdd_filters.py
from pytest_bdd import scenario, given, when, then, parsers

from cendry import FieldFilter, And, Or, Model, Field
from cendry.filters import Filter

FEATURES = "features"


@scenario(f"{FEATURES}/filters.feature", "Create a field filter")
def test_create_field_filter():
    pass


@scenario(f"{FEATURES}/filters.feature", "Compose filters with AND")
def test_compose_and():
    pass


@scenario(f"{FEATURES}/filters.feature", "Compose filters with OR")
def test_compose_or():
    pass


@scenario(f"{FEATURES}/filters.feature", "Field descriptor produces a filter")
def test_field_descriptor_filter():
    pass


@given(parsers.parse('a FieldFilter with field "{field}", operator "{op}" and value "{value}"'), target_fixture="field_filter")
def create_field_filter(field, op, value):
    return FieldFilter(field, op, value)


@then("the filter is a valid Firestore FieldFilter")
def check_firestore_filter(field_filter):
    from google.cloud.firestore_v1.base_query import FieldFilter as FsFieldFilter
    assert isinstance(field_filter, FsFieldFilter)


@given("two field filters", target_fixture="two_filters")
def two_filters():
    return FieldFilter("state", "==", "CA"), FieldFilter("population", ">", 1000000)


@when("I combine them with And", target_fixture="composite")
def combine_and(two_filters):
    return And(*two_filters)


@when("I combine them with Or", target_fixture="composite")
def combine_or(two_filters):
    return Or(*two_filters)


@then("the result is a composite filter")
def check_composite(composite):
    assert isinstance(composite, Filter)


@given(parsers.parse('a model with a "{field_name}" field'), target_fixture="test_model")
def model_with_field(field_name):
    return type("TestModel", (Model,), {"__annotations__": {field_name: Field[str]}}, collection="test")


@when(parsers.parse('I call eq("{value}") on the field descriptor'), target_fixture="filter_result")
def call_eq(test_model, value):
    return test_model.state.eq(value)


@then(parsers.parse('the result is a filter with operator "{op}"'))
def check_filter_op(filter_result, op):
    assert isinstance(filter_result, Filter)
    assert filter_result.op == op
```

- [ ] **Step 5: Write context.feature**

```gherkin
# tests/features/context.feature
Feature: Context operations
    As a developer
    I want to query Firestore documents through an ODM context
    So that I get typed model instances

    Scenario: Get a document by ID
        Given a Firestore collection "cities" with a document "SF"
        When I call get with model City and id "SF"
        Then I receive a City instance with id "SF"

    Scenario: Get a non-existent document raises error
        Given a Firestore collection "cities" without document "NOPE"
        When I call get with model City and id "NOPE"
        Then a DocumentNotFound error is raised

    Scenario: Find a non-existent document returns None
        Given a Firestore collection "cities" without document "NOPE"
        When I call find with model City and id "NOPE"
        Then the result is None
```

- [ ] **Step 6: Write test_bdd_context.py step definitions**

```python
# tests/test_bdd_context.py
from unittest.mock import MagicMock

import pytest
from pytest_bdd import scenario, given, when, then, parsers

from cendry import Cendry, DocumentNotFound
from tests.conftest import City, make_mock_document

FEATURES = "features"


@scenario(f"{FEATURES}/context.feature", "Get a document by ID")
def test_get_document():
    pass


@scenario(f"{FEATURES}/context.feature", "Get a non-existent document raises error")
def test_get_not_found():
    pass


@scenario(f"{FEATURES}/context.feature", "Find a non-existent document returns None")
def test_find_not_found():
    pass


@given(parsers.parse('a Firestore collection "{col}" with a document "{doc_id}"'), target_fixture="context_and_id")
def collection_with_doc(doc_id):
    client = MagicMock()
    doc = make_mock_document(doc_id, {
        "name": "San Francisco", "state": "CA", "country": "USA",
        "capital": False, "population": 870000, "regions": ["west_coast"],
    })
    client.collection.return_value.document.return_value.get.return_value = doc
    return Cendry(client=client), doc_id


@given(parsers.parse('a Firestore collection "{col}" without document "{doc_id}"'), target_fixture="context_and_id")
def collection_without_doc(doc_id):
    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), doc_id


@when(parsers.parse('I call get with model City and id "{doc_id}"'), target_fixture="get_result")
def call_get(context_and_id, doc_id):
    ctx, _ = context_and_id
    try:
        return ctx.get(City, doc_id)
    except DocumentNotFound as e:
        return e


@when(parsers.parse('I call find with model City and id "{doc_id}"'), target_fixture="find_result")
def call_find(context_and_id, doc_id):
    ctx, _ = context_and_id
    return ctx.find(City, doc_id)


@then(parsers.parse('I receive a City instance with id "{doc_id}"'))
def check_city(get_result, doc_id):
    assert isinstance(get_result, City)
    assert get_result.id == doc_id


@then("a DocumentNotFound error is raised")
def check_not_found(get_result):
    assert isinstance(get_result, DocumentNotFound)


@then("the result is None")
def check_none(find_result):
    assert find_result is None
```

- [ ] **Step 7: Write query.feature**

```gherkin
# tests/features/query.feature
Feature: Query ordering
    As a developer
    I want to order query results
    So that I get documents in the right order

    Scenario: Create ascending order
        Given an Asc directive on field "population"
        Then the direction is "ASCENDING"

    Scenario: Create descending order
        Given a Desc directive on field "population"
        Then the direction is "DESCENDING"
```

- [ ] **Step 8: Write test_bdd_query.py step definitions**

```python
# tests/test_bdd_query.py
from pytest_bdd import scenario, given, then, parsers

from cendry import Asc, Desc

FEATURES = "features"


@scenario(f"{FEATURES}/query.feature", "Create ascending order")
def test_ascending():
    pass


@scenario(f"{FEATURES}/query.feature", "Create descending order")
def test_descending():
    pass


@given(parsers.parse('an Asc directive on field "{field}"'), target_fixture="order")
def asc_directive(field):
    return Asc(field)


@given(parsers.parse('a Desc directive on field "{field}"'), target_fixture="order")
def desc_directive(field):
    return Desc(field)


@then(parsers.parse('the direction is "{direction}"'))
def check_direction(order, direction):
    assert order.direction == direction
```

- [ ] **Step 9: Run all tests**

```bash
uv run pytest -v
```

Expected: All unit and BDD tests pass.

- [ ] **Step 10: Commit**

```bash
git add -A && git commit -m "feat: add BDD feature files and step definitions"
```

---

### Task 11: Public API Exports and Final Verification

**Files:**
- Modify: `src/cendry/__init__.py`

- [ ] **Step 1: Write test for public API completeness**

```python
# append to tests/test_model.py or create tests/test_public_api.py
def test_public_api_exports():
    import cendry
    expected = [
        "Model", "Map", "Field", "field",
        "Cendry", "AsyncCendry",
        "FieldFilter", "And", "Or",
        "Asc", "Desc",
        "CendryError", "DocumentNotFound",
    ]
    for name in expected:
        assert hasattr(cendry, name), f"Missing export: {name}"
    assert set(expected) == set(cendry.__all__)
```

- [ ] **Step 2: Finalize __init__.py**

```python
# src/cendry/__init__.py
"""Cendry — A Firestore ODM for Python."""

from cendry.context import AsyncCendry, Cendry
from cendry.exceptions import CendryError, DocumentNotFound
from cendry.filters import And, FieldFilter, Or
from cendry.model import Field, Map, Model, field
from cendry.query import Asc, Desc

__all__ = [
    "And",
    "Asc",
    "AsyncCendry",
    "Cendry",
    "CendryError",
    "Desc",
    "DocumentNotFound",
    "Field",
    "FieldFilter",
    "Map",
    "Model",
    "Or",
    "field",
]
```

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

```bash
uv run coverage run -m pytest -v
uv run coverage report --show-missing
```

Expected: All tests pass, 100% coverage.

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

```bash
uv run mypy src/cendry/
```

Expected: Success, no errors.

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

```bash
uv run ty check src/cendry/
```

Expected: Success.

- [ ] **Step 6: Commit**

```bash
git add -A && git commit -m "feat: finalize public API exports and verify full coverage"
```

---

### Task 12: README

**Files:**
- Modify: `README.md`

- [ ] **Step 1: Write README with usage examples**

Write a README covering: installation, model definition, querying (get/find/select), filters (FieldFilter and descriptors), composition (& / |), ordering/pagination, subcollections, collection groups, sync/async usage, and Map for embedded data.

- [ ] **Step 2: Commit**

```bash
git add README.md && git commit -m "docs: add README with usage examples"
```
