ADR-007 — @contract Marker for Cross-Service Schema Governance¶
Status: Accepted — v3.2.1
Context¶
As the ecosystem grows past a handful of services, the same domain shape
(e.g. an Item, an Artifact) tends to get redefined independently in
each service that touches it — once as a Postgres row mapping, once as a
Kafka message payload, once as an HTTP response model. Nothing in
openframe-core identifies which Pydantic models are published
contracts — shapes that cross a service boundary (REST, Kafka, gRPC, or
any other transport) and therefore need governance against breaking
changes — versus purely internal models that are free to change at will.
Governing cross-service contracts (exporting a versioned schema, diffing two versions for breaking changes, publishing to a registry) is a substantial, independently-versioned concern with its own dependency footprint — it does not belong in the platform kernel, which stays infrastructure-free by design (ADR-002). But something has to mark which models are contracts in the first place, and that marker has to live somewhere every service can import without pulling in the full governance machinery.
Decision¶
Add a single new module, openframe.core.schemas, containing only a
marker — no export logic, no registry, no diffing:
contract(name: str, version: str)— a decorator that attaches aContractMeta(name, version)instance to the decorated class ascls.__contract__and returns the class unchanged. It does not wrap, subclass, or alter validation/serialization behaviour in any way — Pydantic (or any other class) is unaffected at runtime.ContractMeta— a plain dataclass carryingnameandversion, the two fields the downstream diffing tool needs to identify a contract independently of its Python class name.get_contract_meta(cls)— a safe accessor returning the attachedContractMeta, orNonefor any undecorated class.
from openframe.core.schemas import contract
@contract(name="item", version="1.0")
class Item(BaseModel):
id: str
name: str
The module is deliberately stdlib-only — no pydantic import, no
openframe.core imports from any other module. This keeps schemas at
the bottom of the dependency DAG alongside exceptions, so decorating a
model never adds a dependency to a service's domain layer, regardless
of whether that service ever installs the governance tooling.
The machinery that acts on the marker — schema export to JSON Schema,
a versioned registry, breaking-change diffing, a CI-gateable check
command — lives entirely in the separate openframe-schemas package
(part of the openframe-tooling monorepo), which has its own
dependencies (starting with pydantic itself, to introspect the
decorated model) and its own release cadence. openframe-core only
ever needs to know that a marker exists and where to read it from.
Consequences¶
- New apex-adjacent module, zero new dependencies.
openframe.core.schemasjoinsexceptionsandconfigas a module importable with no transitive dependency beyond the stdlib. Every existing service can adopt@contractimmediately without installingopenframe-schemas. - The decorator is inert without the downstream package. Decorating
a model with
@contractand never installingopenframe-schemasis harmless —__contract__sits unused as a class attribute. This is intentional: adoption of the marker and adoption of the governance tooling are two independent decisions. - No enforcement lives in core.
openframe-corecannot and does not validate that aname/versionpair is unique, well-formed, or non-breaking relative to a prior version — all of that isopenframe-schemas' job, by design, so that core's release cadence is never coupled to the governance tool's feature set. - Stability. The decorator's signature (
name,version) is stable;ContractMetamay gain additional fields in a future minor version (e.g. an owning team, a deprecation flag) — additive only, consistent with the rest of the ecosystem's no-breaking-changes-within-a-major policy.
Alternatives considered¶
- Put the marker inside
openframe-schemasitself, so services that want@contractinstall that package directly. Rejected: this would force every service defining a domain model — the overwhelming majority of which never touch schema diffing — to add a dependency and its transitive footprint just to tag a class. Splitting the inert marker from the active tooling means the cost of marking a contract is zero, and only the cost of governing it is opt-in. - Use a plain class-level attribute instead of a decorator
(
class Item(BaseModel): __contract_name__ = "item"). Rejected: a decorator is more discoverable at the definition site, composes with any base class (not just Pydantic), and keeps the marker's shape (ContractMeta) as a single typed object rather than two loosely-related class attributes a reader has to know to look for together.
See openframe-tooling's
openframe-schemas package for the export/registry/diff machinery that
reads this marker, and its README's "Breaking-change rules" table for
the compatibility classification openframe-schemas applies once a
model is tagged.