Skip to content
GitHub
Decisions

ADR-068: Pragmatic methods on kernel value types (clarifies ADR-065 D1)

Status: Accepted (2026-04-29) Supersedes: ADR-065 D1

Context

ADR-065 D1 specified the kernel admission discipline with the literal phrase “No logic functions or methods beyond Pydantic field validators.” That phrasing was conservative by design — Phase 1–3 of the kernel-redesign orchestration hadn’t fully surveyed the kinds of methods that would land in core, and a strict “no methods” rule was the easiest one to enforce closed.

Phase 5 / M3 surfaced the pragmatic case the strict reading does not handle. Three M3 refactors converted top-level kernel logic functions into methods on the relevant Pydantic value types:

  • RetryPolicy.classify(self, exc) -> bool (was classify_failure(exc, policy))
  • RetryPolicy.delay_for(self, attempt) -> float (was compute_delay(attempt, policy))
  • RetentionPolicy.resolve(cls, entity_type, content_class) -> RetentionPolicy (was policy_for(entity_type, content_class))

Each method takes only self (or cls) plus value-typed arguments, returns a value-typed result, and reaches no external state beyond module-level Final-typed constants in the same core/<subdir>/ package. None of them introduce orchestration between contexts, side effects, or framework-layer dependencies. They are intrinsic operations on the value type — the same kind of method a DDD value object carries (Money.add(other) -> Money, Range.contains(value) -> bool).

The strict reading of ADR-065 D1 forbids these methods and forces the logic out of core. That outcome creates ergonomic friction without preserving any load-bearing invariant: the orchestration risk ADR-065 D1 defends against lives in what the method’s args carry, not in whether methods are present. A method whose args reach into context-internal vocabulary or framework-layer machinery is dangerous; a method that operates only on self and value-typed inputs is not.

This ADR names the principle ADR-065 D1 was reaching for and supersedes its literal “no methods beyond field validators” phrasing.

Decision

A method on a kernel value type is admitted iff it is a pure function of self (or cls) plus value-typed arguments, returning a value-typed result.

What “value-typed” means here

A type is value-typed for the purposes of method-argument and return annotations iff it is one of:

  • A stdlib type (int, str, float, bool, bytes, bytearray, tuple, list, dict, set, frozenset, UUID, datetime, date, time, Decimal, Path, Enum and its subclasses, exception classes, etc.).
  • A Final-typed constant or Literal.
  • A frozen Pydantic model defined in the kernel (any core/<subdir>/).
  • A Protocol / TypedDict defined in the kernel.
  • An enum / StrEnum / IntEnum defined in the kernel.
  • An exception class defined in the kernel.
  • BaseException and its stdlib subclasses (handlers classify exceptions by type; this is a value-typed shape).

A type is not value-typed iff it is one of:

  • A type imported from <context>.application/ or <context>.infrastructure/ (any context beneath spectral.<context> outside <context>.contracts/). These are context-internal surfaces; methods that take them couple the kernel to context implementation.
  • A type imported from apps/* packages. These are framework-layer composition seams; methods that take them push framework concerns into core.
  • The substrate-transport callee Protocols EventPublisher and EventListener from core/events/protocols. These name the framework-to-context composition seam. A kernel value type accepting an EventPublisher argument would mean the type is orchestrating emission, which belongs at the context infrastructure or framework layer, not in core.

What this admits + excludes concretely

Admitted (intrinsic-method shape):

  • RetryPolicy.classify(self, exc: BaseException) -> bool
  • RetryPolicy.delay_for(self, attempt: int) -> float
  • RetentionPolicy.resolve(cls, entity_type: str, content_class: ContentClass) -> RetentionPolicy
  • Field validators (@field_validator) and model validators (@model_validator) — unchanged from ADR-065 D1’s literal admission.
  • Computed fields (@computed_field) when the computation depends only on other fields of the same model.
  • __str__ / __repr__ / __eq__ / __hash__ overrides on Pydantic / enum / value-object types. These are language-level conformance methods.

Excluded (behavioral-method shape):

  • RetryPolicy.notify_failure(self, exc, *, db_session) — DB session is not value-typed.
  • EmbeddingProfile.fetch_for(self, account_id, *, db) — DB session arg.
  • EventEnvelope.emit(self, *, publisher: EventPublisher)EventPublisher is the framework-to-context seam.
  • RuleCandidate.publish_to_worlds(self) — names a specific context; behavior reaching another context belongs outside the kernel.
  • Any method accepting <context>.application.* or <context>.infrastructure.* types.

Enforcement

Validator rule 3 (tools/quality/validate_architecture.py) extends to inspect class-internal FunctionDef and AsyncFunctionDef parameter annotations:

  • If any parameter annotation imports from <context>.application/, <context>.infrastructure/, or apps/* packages, the method is rejected.
  • If any parameter annotation references EventPublisher or EventListener from core.events.protocols, the method is rejected.

Other annotation shapes pass; the validator is intentionally lenient for any type it cannot statically classify (callers may use forward references, typing.TYPE_CHECKING guards, or types from core/<other-subdir>/ that the validator does not deeply trace).

Consequences

  • The M3 refactors land cleanly under doctrine. RetryPolicy.classify, RetryPolicy.delay_for, and RetentionPolicy.resolve are admitted.
  • Validator rule 3 grows by ~80 lines to inspect method signatures. Cost is one-time; pre-push + CI run unchanged.
  • The kernel becomes ergonomically habitable for value-object semantics. Domain-modeling teams who reach for “value object with intrinsic operations” patterns can apply them in core/ without forcing pure-data Pydantic shapes that push every operation outside the type.
  • The orchestration-prevention principle stays intact. Methods that take framework-layer or context-internal types are still rejected. The defense lives at the type-of-arg level, not at the presence-of-method level.
  • ADR-065 D1’s other clauses remain authoritative. The frozen-Pydantic / Protocol / enum / TypedDict / Literal / Final / exception / stdlib type shape constraint, the no-mutable-state rule, the no-context-internal-aggregates rule — all unchanged.

Alternatives considered

  • Keep the strict literal reading. The orchestration-prevention defense doesn’t actually need it (defense lives in the args, not the methods); the cost is forcing every value-object operation out of core. Rejected.
  • Allow only @classmethod factories, not instance methods. Would admit RetentionPolicy.resolve but exclude RetryPolicy.classify / RetryPolicy.delay_for. The instance/classmethod distinction does not track the orchestration risk; both shapes are equally safe when args are value-typed. Rejected.
  • Allow methods only on value types, not on Protocols. Protocols already declare methods (that’s their point); restricting to “no extra methods on value types” would be inverting which class kind got the privilege. Rejected.
  • Replace ADR-065 wholesale with a successor that re-states D1–D9. Heavier than needed. Whole-ADR supersession is reserved for cases where the original ADR’s framing is no longer load-bearing. ADR-065’s other 8 decisions stand; D1 needs precision, not replacement. Rejected per Spectral’s whole-ADR-supersession-only doctrine.