- Status: Accepted (consequences amended by ADR 0008 / #730; see Amendment)
- Date: 2026-10-02
- Issue: #723 (part of #706). The ADR process itself (template, index, numbering) is #731; this record follows the shape #731 proposes (context, decision, consequences, status).
The contributor docs and the code disagreed about how the SDK models data:
CONTRIBUTING.md§3 told contributors to use pydantic v2 idioms (model_validate,Field,model_config), andARCHITECTURE.mddescribedcore/in terms of "httpx + pydantic".- The code does something else. Every value object in
src/donkey_kitis a@dataclass(frozen=True): theDonkeyConfigfamily incore/config.py, theregistry/models.pyassets,core/toolspec.py(whose docstring says "no pydantic"), thellm/catalog.pyentries, and the rest. Changes produce a new instance (DonkeyConfig.with_overrides(...),dataclasses.replace(...)). Keyword-override bags are typed withTypedDict+Unpack(core/config.pyConfigOverrides), not with a model class. pydantic>=2.6is a base dependency (python/pyproject.toml), but the only module insrc/that imports it isprovisioning/spec.py. That module parses a user-authored YAML document (DonkeySpec.from_yaml) against a schema, and it belongs to the legacy provisioning half of the package (ARCHITECTURE.md, "Not in the stack —provisioning/").
The build plan (§1.1, "httpx + pydantic only") sets the upper bound on what
core/ may depend on. It does not require pydantic, and core/ imports none.
- Value objects are
@dataclass(frozen=True). Configuration, catalog entries, registry assets, tool specs, results and similar records that the SDK hands around or returns are frozen dataclasses. To change one, build a new instance (dataclasses.replace(...)or a typed helper such aswith_overrides(...)); don't mutate it. - Typed keyword bags are
TypedDict, used withUnpack[...]on the signature, so the overrides stay checked bymypy --strictwithout a model class. - A plain (non-frozen)
@dataclassis for mutable internal state only, such as a private cache entry (core/cache.py_Entry) or a result being built up step by step (provisioning/applier.pyApplyResult). It is not used for a value the caller is meant to treat as immutable. - pydantic is used only at an external-schema boundary: where the SDK
validates a document it does not control against a declared schema, and
pydantic's validation and error reporting are the point. Today the only such
boundary is
provisioning/spec.py. A new pydantic model needs that justification in its PR. core/never imports pydantic. This is stricter than the build plan's§1.1allowance and matches what the code already does.
Framework-owned pydantic objects (for example a CrewAI tool's args_schema, or
ADK's pydantic config) are the framework's types. An adapter can read or pass
them, and that is not a use of pydantic by the SDK under this decision.
- pydantic v2 models for value objects (what the docs said). Rejected:
nothing in
src/outsideprovisioning/spec.pyuses them, so adopting them would mean rewriting the existing value objects. It would also put a runtime dependency under every value object for validation the SDK doesn't need on internal data, and keepimport donkey_kittied to pydantic. - attrs, or
NamedTuple. Rejected: attrs adds a dependency for what the stdlib already covers, andNamedTupleis a tuple (it can be indexed and unpacked by position), which these records shouldn't be.
CONTRIBUTING.md§3 andARCHITECTURE.md(core in the layered stack) now state this convention instead of the pydantic idioms.pydanticstays a base dependency for now, becauseprovisioning/spec.pyimports it at module level anddonkey_kit.provisioningexportsDonkeySpec. Dropping it from the base dependencies and importing it lazily in whatever remains of provisioning is #730's job (the architecture epic); this ADR is the decision that work implements.- The
pydantic.mypyplugin stays on inpyproject.tomlwhileprovisioning/spec.pyexists. - The build plan's
§1.1wording ("httpx + pydantic only") is left alone. It is an upper bound, and the decision here sits inside it. - Reviewers can reject a new pydantic model outside an external-schema boundary, and a mutable dataclass used as a value object, by citing this ADR.
The decision above stands. Three of the consequences were written for the
tree as it was before #730 and stop being true when #730 lands, because #730
deletes provisioning/spec.py, the only pydantic importer (ADR 0008):
pydanticis no longer a base dependency.[project] dependenciesishttpxplus thetyping-extensionsandtomlibackfills (python/pyproject.toml), andtests/unit/test_base_install_deps.pyholds that. The "stays a base dependency for now" bullet is superseded in part.- The
pydantic.mypyplugin is off:[tool.mypy]no longer lists it. The "stays on whileprovisioning/spec.pyexists" bullet is superseded in part. - Decision point 4 ("today the only such boundary is
provisioning/spec.py") now describes no boundary at all: the SDK has no pydantic model. The rule stays for the next one, which imports pydantic lazily and declares it in an extra (ADR 0008, decision 4).