# Field Type Validation 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:** Validate `Field[T]` annotations at class definition time, rejecting non-Firestore types with clear error messages.

**Architecture:** A `TypeRegistry` class in `src/cendry/types.py` holds allowed scalar types, container rules, and structured type checkers (with try/except for pydantic, attrs, msgspec). The metaclass `_MapMeta` calls `default_registry.validate()` for each annotation, replacing the existing `_validate_no_nested_models`. Contexts accept an optional `type_registry` override.

**Tech Stack:** Python >= 3.13, pytest, pytest-bdd, mypy, ty, ruff

---

## File Map

| File | Responsibility |
|------|---------------|
| `src/cendry/types.py` | **New** — `TypeRegistry`, `default_registry`, `register_type` |
| `src/cendry/model.py` | Remove `_validate_no_nested_models`, `_get_inner_type`; call `default_registry.validate()` |
| `src/cendry/context.py` | Add optional `type_registry` param to `Cendry`/`AsyncCendry` |
| `src/cendry/__init__.py` | Export `TypeRegistry`, `register_type` |
| `tests/test_types.py` | **New** — unit tests for `TypeRegistry` |
| `tests/features/types.feature` | **New** — BDD scenarios |
| `tests/test_bdd_types.py` | **New** — BDD step definitions |

---

### Task 1: TypeRegistry — Scalar and SDK Type Validation

**Files:**
- Create: `src/cendry/types.py`
- Create: `tests/test_types.py`

- [ ] **Step 1: Write failing tests for scalar validation**

```python
# tests/test_types.py
import datetime
from decimal import Decimal

import pytest

from cendry.types import TypeRegistry


@pytest.fixture
def registry() -> TypeRegistry:
    return TypeRegistry()


def test_str_is_valid(registry: TypeRegistry):
    registry.validate("name", str, "TestModel")


def test_int_is_valid(registry: TypeRegistry):
    registry.validate("count", int, "TestModel")


def test_float_is_valid(registry: TypeRegistry):
    registry.validate("score", float, "TestModel")


def test_bool_is_valid(registry: TypeRegistry):
    registry.validate("active", bool, "TestModel")


def test_bytes_is_valid(registry: TypeRegistry):
    registry.validate("data", bytes, "TestModel")


def test_decimal_is_valid(registry: TypeRegistry):
    registry.validate("price", Decimal, "TestModel")


def test_datetime_is_valid(registry: TypeRegistry):
    registry.validate("created", datetime.datetime, "TestModel")


def test_complex_is_invalid(registry: TypeRegistry):
    with pytest.raises(TypeError, match="complex"):
        registry.validate("val", complex, "TestModel")


def test_object_is_invalid(registry: TypeRegistry):
    with pytest.raises(TypeError, match="object"):
        registry.validate("val", object, "TestModel")
```

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

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

Expected: ImportError — `cendry.types` not found.

- [ ] **Step 3: Write failing tests for Firestore SDK types**

```python
# append to tests/test_types.py
from google.cloud.firestore_v1._helpers import GeoPoint
from google.cloud.firestore_v1.base_document import DocumentReference


def test_geopoint_is_valid(registry: TypeRegistry):
    registry.validate("location", GeoPoint, "TestModel")


def test_document_reference_is_valid(registry: TypeRegistry):
    registry.validate("ref", DocumentReference, "TestModel")
```

- [ ] **Step 4: Implement TypeRegistry with scalar and SDK validation**

```python
# src/cendry/types.py
import dataclasses
import datetime
import types
from collections.abc import Callable
from decimal import Decimal
from typing import Any, get_args, get_origin, is_typeddict

from google.cloud.firestore_v1._helpers import GeoPoint
from google.cloud.firestore_v1.base_document import DocumentReference

_SCALAR_TYPES: frozenset[type] = frozenset({
    str, int, float, bool, bytes,
    Decimal,
    datetime.datetime,
    GeoPoint,
    DocumentReference,
})

_CONTAINER_TYPES: frozenset[type] = frozenset({list, tuple, set, dict})


class TypeRegistry:
    """Registry of Firestore-compatible types for Field[T] validation."""

    def __init__(self) -> None:
        self._scalar_types: set[type] = set(_SCALAR_TYPES)
        self._checkers: list[Callable[[type], bool]] = []

    def register(self, type_or_predicate: type | Callable[[type], bool]) -> None:
        """Register a type or predicate as Firestore-compatible."""
        if isinstance(type_or_predicate, type):
            self._scalar_types.add(type_or_predicate)
        else:
            self._checkers.append(type_or_predicate)

    def validate(self, field_name: str, hint: Any, class_name: str) -> None:
        """Validate a type hint is Firestore-compatible. Raises TypeError if not."""
        self._validate_hint(hint, field_name, class_name, context="")

    def _validate_hint(
        self, hint: Any, field_name: str, class_name: str, context: str
    ) -> None:
        # Scalar types
        if isinstance(hint, type) and hint in self._scalar_types:
            return

        raise TypeError(
            f"Field '{field_name}' on '{class_name}': unsupported type "
            f"'{hint.__name__ if isinstance(hint, type) else hint}'"
            f"{f' in {context}' if context else ''}. "
            f"Firestore does not support this type."
        )
```

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

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

Expected: All pass.

- [ ] **Step 6: Run ruff, mypy, ty**

```bash
uv run ruff check src/cendry/types.py tests/test_types.py && uv run mypy src/cendry/types.py && uv run ty check src/cendry/types.py
```

- [ ] **Step 7: Commit**

```bash
git add src/cendry/types.py tests/test_types.py
git commit -m "feat: add TypeRegistry with scalar and SDK type validation"
```

---

### Task 2: Optional and Container Validation

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

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

```python
# append to tests/test_types.py
def test_optional_str_is_valid(registry: TypeRegistry):
    registry.validate("name", str | None, "TestModel")


def test_optional_complex_is_invalid(registry: TypeRegistry):
    with pytest.raises(TypeError, match="complex"):
        registry.validate("val", complex | None, "TestModel")
```

- [ ] **Step 2: Write failing tests for container types**

```python
# append to tests/test_types.py
def test_list_str_is_valid(registry: TypeRegistry):
    registry.validate("tags", list[str], "TestModel")


def test_list_complex_is_invalid(registry: TypeRegistry):
    with pytest.raises(TypeError, match="complex"):
        registry.validate("tags", list[complex], "TestModel")


def test_set_int_is_valid(registry: TypeRegistry):
    registry.validate("ids", set[int], "TestModel")


def test_tuple_str_int_is_valid(registry: TypeRegistry):
    registry.validate("pair", tuple[str, int], "TestModel")


def test_dict_str_int_is_valid(registry: TypeRegistry):
    registry.validate("data", dict[str, int], "TestModel")


def test_dict_int_key_is_invalid(registry: TypeRegistry):
    with pytest.raises(TypeError, match="dict keys must be str"):
        registry.validate("data", dict[int, str], "TestModel")


def test_nested_list_dict_is_valid(registry: TypeRegistry):
    registry.validate("items", list[dict[str, int]], "TestModel")


def test_nested_invalid_inner_type(registry: TypeRegistry):
    with pytest.raises(TypeError, match="complex"):
        registry.validate("items", list[dict[str, complex]], "TestModel")


def test_bare_list_is_valid(registry: TypeRegistry):
    registry.validate("items", list, "TestModel")


def test_bare_dict_is_valid(registry: TypeRegistry):
    registry.validate("data", dict, "TestModel")
```

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

```bash
uv run pytest tests/test_types.py -v -k "optional or list or set or tuple or dict or nested or bare"
```

- [ ] **Step 4: Implement optional and container validation**

Add to `_validate_hint` in `TypeRegistry`:

```python
def _validate_hint(
    self, hint: Any, field_name: str, class_name: str, context: str
) -> None:
    # Scalar types
    if isinstance(hint, type) and hint in self._scalar_types:
        return

    # Bare container types (list, dict, etc. without parameters)
    if isinstance(hint, type) and hint in _CONTAINER_TYPES:
        return

    origin = get_origin(hint)
    args = get_args(hint)

    # Optional: T | None
    if isinstance(hint, types.UnionType):
        non_none = [a for a in args if a is not type(None)]
        for arg in non_none:
            self._validate_hint(arg, field_name, class_name, context)
        return

    # Containers: list[T], set[T], tuple[T1, T2], dict[K, V]
    if origin in _CONTAINER_TYPES:
        if origin is dict and args:
            key_type = args[0]
            if key_type is not str:
                key_name = key_type.__name__ if isinstance(key_type, type) else str(key_type)
                ctx = f"dict[{key_name}, ...]"
                raise TypeError(
                    f"Field '{field_name}' on '{class_name}': "
                    f"dict keys must be str, got {key_name}"
                    f"{f' in {context}' if context else f' in {ctx}'}."
                )
            if len(args) > 1:
                val_ctx = f"dict[str, ...]"
                self._validate_hint(args[1], field_name, class_name, val_ctx)
        elif args:
            container_name = origin.__name__
            for arg in args:
                self._validate_hint(
                    arg, field_name, class_name, f"{container_name}[...]"
                )
        return

    # Structured types — checked below in Task 3
    # ...

    type_name = hint.__name__ if isinstance(hint, type) else str(hint)
    raise TypeError(
        f"Field '{field_name}' on '{class_name}': unsupported type "
        f"'{type_name}'"
        f"{f' in {context}' if context else ''}. "
        f"Firestore does not support this type."
    )
```

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

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

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: add optional and container type validation with recursion"
```

---

### Task 3: Structured Type Validation (Map, dataclass, TypedDict, third-party)

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

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

```python
# append to tests/test_types.py
from typing import TypedDict

from cendry import Map, Model, Field


def test_map_subclass_is_valid(registry: TypeRegistry):
    class Mayor(Map):
        name: Field[str]

    registry.validate("mayor", Mayor, "TestModel")


def test_dataclass_is_valid(registry: TypeRegistry):
    @dataclasses.dataclass
    class Point:
        x: float
        y: float

    registry.validate("location", Point, "TestModel")


def test_typeddict_is_valid(registry: TypeRegistry):
    class Config(TypedDict):
        key: str
        value: int

    registry.validate("config", Config, "TestModel")


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

    with pytest.raises(TypeError, match="cannot nest"):
        registry.validate("city", City, "TestModel")


def test_unknown_class_is_invalid(registry: TypeRegistry):
    class RandomClass:
        pass

    with pytest.raises(TypeError):
        registry.validate("val", RandomClass, "TestModel")


import dataclasses
```

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

```bash
uv run pytest tests/test_types.py -v -k "map or dataclass or typeddict or model_is_invalid or unknown"
```

- [ ] **Step 3: Implement structured type validation**

Add to `_validate_hint` before the final error, and add `_is_structured_type` method:

```python
def _validate_hint(
    self, hint: Any, field_name: str, class_name: str, context: str
) -> None:
    # ... (scalar, bare container, optional, parameterized container from Task 2)

    # Structured types
    if isinstance(hint, type):
        # Model subclass — reject nesting
        from .model import Model
        if issubclass(hint, Model):
            raise TypeError(
                f"Field '{field_name}' on '{class_name}': "
                f"cannot nest Model '{hint.__name__}'. "
                f"Use Map for embedded data."
            )

        # Map subclass, dataclass, TypedDict — accept
        from .model import Map
        if issubclass(hint, Map):
            return
        if dataclasses.is_dataclass(hint):
            return
        if is_typeddict(hint):
            return

        # Custom checkers
        for checker in self._checkers:
            try:
                if checker(hint):
                    return
            except TypeError:
                continue

    # Invalid
    type_name = hint.__name__ if isinstance(hint, type) else str(hint)
    raise TypeError(
        f"Field '{field_name}' on '{class_name}': unsupported type "
        f"'{type_name}'"
        f"{f' in {context}' if context else ''}. "
        f"Firestore does not support this type."
    )
```

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

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

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: add structured type validation (Map, dataclass, TypedDict)"
```

---

### Task 4: Third-Party Type Detection and Custom Registration

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

- [ ] **Step 1: Write failing tests for custom registration**

```python
# append to tests/test_types.py
def test_register_custom_type(registry: TypeRegistry):
    class MyType:
        pass

    registry.register(MyType)
    registry.validate("val", MyType, "TestModel")


def test_register_predicate(registry: TypeRegistry):
    class CustomClass:
        __custom__ = True

    registry.register(lambda cls: hasattr(cls, "__custom__"))
    registry.validate("val", CustomClass, "TestModel")


def test_predicate_not_matching(registry: TypeRegistry):
    class PlainClass:
        pass

    registry.register(lambda cls: hasattr(cls, "__custom__"))
    with pytest.raises(TypeError):
        registry.validate("val", PlainClass, "TestModel")
```

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

These should already pass from the Task 3 implementation. Verify:

```bash
uv run pytest tests/test_types.py -v -k "register"
```

- [ ] **Step 3: Add third-party detection to default_registry setup**

Add to bottom of `src/cendry/types.py`:

```python
# Global default registry
default_registry = TypeRegistry()

# Third-party structured type detection
try:
    from pydantic import BaseModel as PydanticBaseModel

    default_registry.register(lambda cls: issubclass(cls, PydanticBaseModel))
except ImportError:
    pass

try:
    import attrs

    default_registry.register(lambda cls: attrs.has(cls))
except ImportError:
    pass

try:
    from msgspec import Struct as MsgspecStruct

    default_registry.register(lambda cls: issubclass(cls, MsgspecStruct))
except ImportError:
    pass


def register_type(type_or_predicate: type | Callable[[type], bool]) -> None:
    """Register a type or predicate as Firestore-compatible in the global registry."""
    default_registry.register(type_or_predicate)
```

- [ ] **Step 4: Write test for default_registry and register_type**

```python
# append to tests/test_types.py
from cendry.types import default_registry, register_type


def test_default_registry_accepts_str():
    default_registry.validate("name", str, "Test")


def test_register_type_function():
    class AnotherType:
        pass

    register_type(AnotherType)
    default_registry.validate("val", AnotherType, "Test")
```

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

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

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: add default_registry, register_type, third-party detection"
```

---

### Task 5: Integrate with Metaclass

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

- [ ] **Step 1: Write failing test for type validation in model definition**

```python
# append to tests/test_model.py
def test_model_rejects_invalid_field_type():
    with pytest.raises(TypeError, match="complex"):

        class Bad(Model, collection="bad"):
            val: Field[complex]


def test_map_rejects_invalid_field_type():
    with pytest.raises(TypeError, match="complex"):

        class Bad(Map):
            val: Field[complex]


def test_model_accepts_list_str():
    class Good(Model, collection="good_list"):
        tags: Field[list[str]]

    assert Good.__collection__ == "good_list"


def test_model_accepts_dict_str_int():
    class Good(Model, collection="good_dict"):
        data: Field[dict[str, int]]

    assert Good.__collection__ == "good_dict"
```

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

```bash
uv run pytest tests/test_model.py -v -k "rejects_invalid or accepts_list or accepts_dict"
```

Expected: `test_model_rejects_invalid_field_type` and `test_map_rejects_invalid_field_type` FAIL (no validation yet), the `accepts_` tests PASS.

- [ ] **Step 3: Modify metaclass to use TypeRegistry**

In `src/cendry/model.py`, replace `_validate_no_nested_models` call with registry validation:

```python
# Remove these functions from model.py:
# - _get_inner_type
# - _validate_no_nested_models

# In _MapMeta.__new__, replace line 167:
#     _validate_no_nested_models(cls, raw_annotations)
# with:
        from .types import default_registry
        for attr_name, hint in raw_annotations.items():
            inner = _unwrap_field_type(hint)
            default_registry.validate(attr_name, inner, name)
```

Also remove the `import types` if it's only used by `_get_inner_type`.

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

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

Expected: All tests pass. The existing `test_model_cannot_nest_model` and `test_map_cannot_nest_model` should still pass (the "cannot nest" error now comes from the registry).

- [ ] **Step 5: Run ruff, mypy, ty**

```bash
uv run ruff check src/ tests/ && uv run mypy src/cendry/ tests/ && uv run ty check src/cendry/ tests/
```

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "refactor: replace _validate_no_nested_models with TypeRegistry validation"
```

---

### Task 6: Context type_registry Parameter

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

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

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


def test_cendry_accepts_type_registry(mock_firestore_client: MagicMock):
    registry = TypeRegistry()
    ctx = Cendry(client=mock_firestore_client, type_registry=registry)
    assert ctx.type_registry is registry


def test_cendry_default_registry(mock_firestore_client: MagicMock):
    from cendry.types import default_registry

    ctx = Cendry(client=mock_firestore_client)
    assert ctx.type_registry is default_registry


@pytest.mark.anyio
async def test_async_cendry_accepts_type_registry(mock_firestore_client: MagicMock):
    registry = TypeRegistry()
    ctx = AsyncCendry(client=mock_firestore_client, type_registry=registry)
    assert ctx.type_registry is registry
```

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

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

- [ ] **Step 3: Add type_registry to Cendry and AsyncCendry**

In `src/cendry/context.py`:

```python
from .types import TypeRegistry, default_registry

class _BaseCendry:
    _client: Any
    type_registry: TypeRegistry
    # ... existing methods unchanged

class Cendry(_BaseCendry):
    def __init__(
        self,
        *,
        client: Client | None = None,
        type_registry: TypeRegistry | None = None,
    ) -> None:
        self._client = client or Client()
        self.type_registry = type_registry or default_registry

class AsyncCendry(_BaseCendry):
    def __init__(
        self,
        *,
        client: Any = None,
        type_registry: TypeRegistry | None = None,
    ) -> None:
        if client is None:
            from google.cloud.firestore import AsyncClient
            client = AsyncClient()
        self._client = client
        self.type_registry = type_registry or default_registry
```

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

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

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: add type_registry parameter to Cendry and AsyncCendry"
```

---

### Task 7: Public API Exports

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

- [ ] **Step 1: Update public API test**

```python
# Replace tests/test_public_api.py content:
def test_public_api_exports():
    import cendry

    expected = [
        "And",
        "Asc",
        "AsyncCendry",
        "Cendry",
        "CendryError",
        "Desc",
        "DocumentNotFoundError",
        "Field",
        "FieldFilter",
        "Map",
        "Model",
        "Or",
        "TypeRegistry",
        "field",
        "register_type",
    ]
    for name in expected:
        assert hasattr(cendry, name), f"Missing export: {name}"
    assert set(expected) == set(cendry.__all__)
```

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

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

- [ ] **Step 3: Update __init__.py**

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

from .context import AsyncCendry, Cendry
from .exceptions import CendryError, DocumentNotFoundError
from .filters import And, FieldFilter, Or
from .model import Field, Map, Model, field
from .query import Asc, Desc
from .types import TypeRegistry, register_type

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

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

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

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: export TypeRegistry and register_type from cendry"
```

---

### Task 8: BDD Feature File and Step Definitions

**Files:**
- Create: `tests/features/types.feature`
- Create: `tests/test_bdd_types.py`

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

```gherkin
# tests/features/types.feature
Feature: Field type validation
    As a developer
    I want invalid Firestore types rejected at class definition
    So that I catch type errors early

    Scenario Outline: Valid scalar types are accepted
        When I define a model with a field of type "<type>"
        Then the model is created successfully

        Examples:
            | type     |
            | str      |
            | int      |
            | float    |
            | bool     |
            | bytes    |
            | Decimal  |
            | datetime |

    Scenario Outline: Firestore SDK types are accepted
        When I define a model with a field of type "<type>"
        Then the model is created successfully

        Examples:
            | type              |
            | GeoPoint          |
            | DocumentReference |

    Scenario: Invalid scalar type is rejected
        When I define a model with a field of type "complex"
        Then a TypeError is raised with message containing "complex"

    Scenario: Optional valid type is accepted
        When I define a model with a field of type "str | None"
        Then the model is created successfully

    Scenario: Optional invalid type is rejected
        When I define a model with a field of type "complex | None"
        Then a TypeError is raised with message containing "complex"

    Scenario: List of valid type is accepted
        When I define a model with a field of type "list[str]"
        Then the model is created successfully

    Scenario: List of invalid type is rejected
        When I define a model with a field of type "list[complex]"
        Then a TypeError is raised with message containing "complex"

    Scenario: Dict with string keys is accepted
        When I define a model with a field of type "dict[str, int]"
        Then the model is created successfully

    Scenario: Dict with non-string keys is rejected
        When I define a model with a field of type "dict[int, str]"
        Then a TypeError is raised with message containing "dict keys must be str"

    Scenario: Set of valid type is accepted
        When I define a model with a field of type "set[int]"
        Then the model is created successfully

    Scenario: Tuple of valid types is accepted
        When I define a model with a field of type "tuple[str, int]"
        Then the model is created successfully

    Scenario: Nested container with valid types is accepted
        When I define a model with a field of type "list[dict[str, int]]"
        Then the model is created successfully

    Scenario: Nested container with invalid inner type is rejected
        When I define a model with a field of type "list[dict[str, complex]]"
        Then a TypeError is raised with message containing "complex"

    Scenario: Map subclass is accepted
        When I define a model with a field of type "Map"
        Then the model is created successfully

    Scenario: Dataclass is accepted
        When I define a model with a field of type "dataclass"
        Then the model is created successfully

    Scenario: TypedDict is accepted
        When I define a model with a field of type "TypedDict"
        Then the model is created successfully

    Scenario: Model nested in Model is rejected
        When I define a model with a field of type "Model"
        Then a TypeError is raised with message containing "cannot nest"

    Scenario: Unknown class is rejected
        When I define a model with a field of type "UnknownClass"
        Then a TypeError is raised

    Scenario: User-registered type is accepted
        Given a custom class "MyType" registered in the type registry
        When I define a model with a field of type "MyType"
        Then the model is created successfully

    Scenario: User-registered predicate accepts matching types
        Given a predicate that accepts classes with "__custom__" attribute
        When I define a model with a field of type "CustomClass"
        Then the model is created successfully
```

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

```python
# tests/test_bdd_types.py
import dataclasses
import datetime
from decimal import Decimal
from typing import Any, TypedDict

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

from cendry import Field, Map, Model
from cendry.types import default_registry
from google.cloud.firestore_v1._helpers import GeoPoint
from google.cloud.firestore_v1.base_document import DocumentReference

FEATURES = "features"

# Unique collection name counter to avoid class name conflicts
_counter = 0


def _unique_collection() -> str:
    global _counter  # noqa: PLW0603
    _counter += 1
    return f"test_collection_{_counter}"


# --- Type resolution helper ---

_TYPE_MAP: dict[str, Any] = {
    "str": str,
    "int": int,
    "float": float,
    "bool": bool,
    "bytes": bytes,
    "Decimal": Decimal,
    "datetime": datetime.datetime,
    "GeoPoint": GeoPoint,
    "DocumentReference": DocumentReference,
    "complex": complex,
}


def _resolve_type(type_str: str) -> Any:
    """Resolve a type string from feature files to an actual Python type."""
    if type_str in _TYPE_MAP:
        return _TYPE_MAP[type_str]

    # Optional: "str | None"
    if type_str.endswith(" | None"):
        inner = _resolve_type(type_str[:-7])
        return inner | None

    # Container: "list[str]", "dict[str, int]", etc.
    if "[" in type_str:
        container_name, rest = type_str.split("[", 1)
        rest = rest.rstrip("]")
        container = {"list": list, "set": set, "tuple": tuple, "dict": dict}[container_name]
        # Split args respecting nested brackets
        args = _split_type_args(rest)
        resolved = tuple(_resolve_type(a.strip()) for a in args)
        return container[resolved] if len(resolved) > 1 else container[resolved[0]]

    # Special types
    if type_str == "Map":
        class TestMap(Map):
            name: Field[str]
        return TestMap

    if type_str == "dataclass":
        @dataclasses.dataclass
        class TestDC:
            x: float
        return TestDC

    if type_str == "TypedDict":
        class TestTD(TypedDict):
            key: str
        return TestTD

    if type_str == "Model":
        class TestModel(Model, collection=_unique_collection()):
            name: Field[str]
        return TestModel

    if type_str == "UnknownClass":
        class UnknownClass:
            pass
        return UnknownClass

    # Check fixtures
    raise ValueError(f"Unknown type string: {type_str}")


def _split_type_args(s: str) -> list[str]:
    """Split type args respecting nested brackets."""
    args = []
    depth = 0
    current = ""
    for ch in s:
        if ch == "[":
            depth += 1
            current += ch
        elif ch == "]":
            depth -= 1
            current += ch
        elif ch == "," and depth == 0:
            args.append(current)
            current = ""
        else:
            current += ch
    if current:
        args.append(current)
    return args


# --- Scenarios ---

@scenario(f"{FEATURES}/types.feature", "Valid scalar types are accepted")
def test_valid_scalars():
    pass

@scenario(f"{FEATURES}/types.feature", "Firestore SDK types are accepted")
def test_sdk_types():
    pass

@scenario(f"{FEATURES}/types.feature", "Invalid scalar type is rejected")
def test_invalid_scalar():
    pass

@scenario(f"{FEATURES}/types.feature", "Optional valid type is accepted")
def test_optional_valid():
    pass

@scenario(f"{FEATURES}/types.feature", "Optional invalid type is rejected")
def test_optional_invalid():
    pass

@scenario(f"{FEATURES}/types.feature", "List of valid type is accepted")
def test_list_valid():
    pass

@scenario(f"{FEATURES}/types.feature", "List of invalid type is rejected")
def test_list_invalid():
    pass

@scenario(f"{FEATURES}/types.feature", "Dict with string keys is accepted")
def test_dict_valid():
    pass

@scenario(f"{FEATURES}/types.feature", "Dict with non-string keys is rejected")
def test_dict_invalid():
    pass

@scenario(f"{FEATURES}/types.feature", "Set of valid type is accepted")
def test_set_valid():
    pass

@scenario(f"{FEATURES}/types.feature", "Tuple of valid types is accepted")
def test_tuple_valid():
    pass

@scenario(f"{FEATURES}/types.feature", "Nested container with valid types is accepted")
def test_nested_valid():
    pass

@scenario(f"{FEATURES}/types.feature", "Nested container with invalid inner type is rejected")
def test_nested_invalid():
    pass

@scenario(f"{FEATURES}/types.feature", "Map subclass is accepted")
def test_map():
    pass

@scenario(f"{FEATURES}/types.feature", "Dataclass is accepted")
def test_dataclass():
    pass

@scenario(f"{FEATURES}/types.feature", "TypedDict is accepted")
def test_typeddict():
    pass

@scenario(f"{FEATURES}/types.feature", "Model nested in Model is rejected")
def test_model_nested():
    pass

@scenario(f"{FEATURES}/types.feature", "Unknown class is rejected")
def test_unknown():
    pass

@scenario(f"{FEATURES}/types.feature", "User-registered type is accepted")
def test_registered_type():
    pass

@scenario(f"{FEATURES}/types.feature", "User-registered predicate accepts matching types")
def test_registered_predicate():
    pass


# --- Steps ---

@when(
    parsers.parse('I define a model with a field of type "{type_str}"'),
    target_fixture="model_result",
)
def define_model_with_type(type_str):
    resolved = _resolve_type(type_str)
    try:
        cls = type(
            f"TestModel_{_unique_collection()}",
            (Model,),
            {"__annotations__": {"val": Field[resolved]}},
            collection=_unique_collection(),
        )
        return ("success", cls)
    except TypeError as e:
        return ("error", e)


@then("the model is created successfully")
def model_created(model_result):
    status, value = model_result
    assert status == "success", f"Expected success, got error: {value}"


@then("a TypeError is raised")
def type_error_raised(model_result):
    status, _ = model_result
    assert status == "error"


@then(parsers.parse('a TypeError is raised with message containing "{text}"'))
def type_error_with_message(model_result, text):
    status, error = model_result
    assert status == "error", f"Expected error, got success"
    assert text in str(error), f"Expected '{text}' in '{error}'"


@given(
    parsers.parse('a custom class "{name}" registered in the type registry'),
    target_fixture="custom_type",
)
def register_custom_type(name):
    cls = type(name, (), {})
    default_registry.register(cls)
    _TYPE_MAP[name] = cls
    return cls


@given(
    parsers.parse('a predicate that accepts classes with "{attr}" attribute'),
    target_fixture="custom_predicate",
)
def register_predicate(attr):
    default_registry.register(lambda cls: hasattr(cls, attr))
    # Create a class with that attribute for later use
    custom_cls = type("CustomClass", (), {attr: True})
    _TYPE_MAP["CustomClass"] = custom_cls
    return custom_cls
```

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

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

Expected: All tests pass.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: add BDD scenarios and step definitions for type validation"
```

---

### Task 9: Coverage and Final Verification

**Files:**
- Possibly modify any file to reach 100% coverage

- [ ] **Step 1: Run full coverage report**

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

- [ ] **Step 2: Add tests for any uncovered lines**

Review the coverage report and add targeted tests for any uncovered branches in `src/cendry/types.py`.

- [ ] **Step 3: Run all verification tools**

```bash
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run mypy src/cendry/ tests/
uv run ty check src/cendry/ tests/
uv run coverage run -m pytest && uv run coverage report --show-missing
```

Expected: All clean, 100% coverage.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "test: achieve 100% coverage for type validation"
```
