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(wasclassify_failure(exc, policy))RetryPolicy.delay_for(self, attempt) -> float(wascompute_delay(attempt, policy))RetentionPolicy.resolve(cls, entity_type, content_class) -> RetentionPolicy(waspolicy_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,Enumand its subclasses, exception classes, etc.). - A
Final-typed constant orLiteral. - A frozen Pydantic model defined in the kernel (any
core/<subdir>/). - A
Protocol/TypedDictdefined in the kernel. - An enum /
StrEnum/IntEnumdefined in the kernel. - An exception class defined in the kernel.
BaseExceptionand 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 beneathspectral.<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
EventPublisherandEventListenerfromcore/events/protocols. These name the framework-to-context composition seam. A kernel value type accepting anEventPublisherargument 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) -> boolRetryPolicy.delay_for(self, attempt: int) -> floatRetentionPolicy.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)—EventPublisheris 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/, orapps/*packages, the method is rejected. - If any parameter annotation references
EventPublisherorEventListenerfromcore.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, andRetentionPolicy.resolveare 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
@classmethodfactories, not instance methods. Would admitRetentionPolicy.resolvebut excludeRetryPolicy.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.