# Field Type Validation Design Spec

## Overview

Add compile-time (class definition) validation to `Field[T]` annotations, ensuring only Firestore-compatible types are used. Invalid types raise `TypeError` immediately when the model class is defined.

**Scope:** Type validation only. Type conversion/serialization is deferred to a separate spec.

**This spec is part 1 of 2:**
1. **Type validation** (this spec) — reject invalid types at class definition time
2. **Type conversion** (future spec) — bidirectional serialization between Python and Firestore types

## TypeRegistry

A `TypeRegistry` class holds the set of allowed types and validates `Field[T]` annotations.

### Allowed Types

**Scalar types (always valid):**
- `str`, `int`, `float`, `bool`, `bytes`
- `decimal.Decimal`
- `datetime.datetime`

**Firestore SDK types (always valid):**
- `google.cloud.firestore_v1.GeoPoint`
- `google.cloud.firestore_v1.DocumentReference` (from `google.cloud.firestore`)

**Container types (validated recursively):**
- `list[T]`, `set[T]` — inner type `T` must be valid
- `tuple[T1, T2, ...]` — each element type must be valid (heterogeneous tuples supported)
- `dict[str, V]` — keys must be `str`, value type `V` must be valid

**Structured types (map to Firestore maps):**
- `Map` subclasses (cendry)
- `@dataclass` classes (`dataclasses.is_dataclass`)
- `TypedDict` (`typing.is_typeddict`)
- pydantic `BaseModel` — if `pydantic` is installed
- attrs classes — if `attrs` is installed
- msgspec `Struct` — if `msgspec` is installed

**Optional:** `T | None` — `T` is unwrapped and validated.

**Invalid (TypeError):**
- `Model` subclasses nested in `Model` (existing rule, absorbed into registry)
- Everything else: `complex`, `object`, `type`, unknown custom classes, etc.

### Registry API

```python
from cendry import TypeRegistry, register_type

# Global default registry — pre-populated with all Firestore types
# Used by the metaclass at class definition time
default_registry: TypeRegistry

# Register a specific class as valid
register_type(MyCustomClass)

# Register a predicate for a family of types
register_type(lambda cls: hasattr(cls, "__my_protocol__"))
```

`register_type(type_or_predicate)` adds to the global `default_registry`.

### Per-Context Override

```python
custom_registry = TypeRegistry()
custom_registry.register(MyCustomClass)
ctx = Cendry(client=..., type_registry=custom_registry)
```

When no `type_registry` is passed, the global `default_registry` is used. The per-context registry is stored for future use by the conversion layer (spec 2).

### Third-Party Detection

Third-party structured types are detected via explicit imports with try/except at module load time:

```python
# In src/cendry/types.py, at import time:
try:
    from pydantic import BaseModel
    default_registry.register(lambda cls: issubclass(cls, BaseModel))
except ImportError:
    pass

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

try:
    from msgspec import Struct
    default_registry.register(lambda cls: issubclass(cls, Struct))
except ImportError:
    pass
```

This avoids false positives from duck typing while keeping detection automatic.

## Recursive Validation

The registry exposes `validate(field_name: str, hint: Any, class_name: str)` which raises `TypeError` for invalid types.

### Validation Rules

| Annotation | Valid? | Reason |
|---|---|---|
| `Field[str]` | Yes | Scalar |
| `Field[Decimal]` | Yes | Scalar |
| `Field[complex]` | No | Not a Firestore type |
| `Field[str \| None]` | Yes | Optional unwrap |
| `Field[complex \| None]` | No | Invalid inner type |
| `Field[list[str]]` | Yes | Container + valid inner |
| `Field[list[complex]]` | No | Invalid inner type |
| `Field[dict[str, int]]` | Yes | String keys + valid value |
| `Field[dict[int, str]]` | No | Dict keys must be `str` |
| `Field[set[int]]` | Yes | Container + valid inner |
| `Field[tuple[str, int]]` | Yes | All elements valid |
| `Field[list[dict[str, int]]]` | Yes | Nested valid |
| `Field[list[dict[str, complex]]]` | No | Nested invalid |
| `Field[Mayor]` (Map) | Yes | Structured type |
| `Field[SomeDataclass]` | Yes | Structured type |
| `Field[SomeTypedDict]` | Yes | Structured type |
| `Field[SomePydanticModel]` | Yes | Structured (if installed) |
| `Field[City]` (Model) | No | Cannot nest Model |
| `Field[RandomClass]` | No | Unknown type |

### Error Messages

Error messages include the full path for nested types:

```
TypeError: Field 'tags' on 'City': unsupported type 'complex' in list[complex].
           Firestore does not support this type.

TypeError: Field 'data' on 'City': dict keys must be str, got int
           in dict[int, str].

TypeError: Field 'ref' on 'Country': cannot nest Model 'City'.
           Use Map for embedded data.
```

## Integration

### Metaclass

`_MapMeta.__new__` calls `default_registry.validate()` for each `Field[T]` annotation. This replaces the existing `_validate_no_nested_models` function, which becomes a case within the registry's validation logic.

### Context

`Cendry` and `AsyncCendry` accept an optional `type_registry` parameter:

```python
class Cendry(_BaseCendry):
    def __init__(self, *, client=None, type_registry=None):
        ...
```

The registry is stored on the context for use by the future conversion layer.

## Project Structure Changes

| File | Change |
|---|---|
| `src/cendry/types.py` | **New** — `TypeRegistry`, `default_registry`, `register_type` |
| `src/cendry/model.py` | Remove `_validate_no_nested_models`, 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/features/types.feature` | **New** — BDD scenarios for type validation |
| `tests/test_bdd_types.py` | **New** — step definitions |
| `tests/test_types.py` | **New** — unit tests for `TypeRegistry` |

## Public API Additions

Added to `cendry.__init__` and `__all__`:
- `TypeRegistry`
- `register_type`

## BDD Scenarios

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

    # --- Scalars ---

    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"

    # --- Optional ---

    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"

    # --- Containers ---

    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"

    # --- Structured types ---

    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

    # --- Custom registration ---

    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
```
