ADR-015 — Generic SES Protocol Types
Context and Problem Statement
Source, Expander, Sink, CrawlPlugin, Ref, Expansion, and
CrawlPlan are all typed against object (ADR-004). Protocol method
parameters are contravariant, so a concrete adapter method narrower than
object — e.g. expand(self, ref: Ref, ...) — does not structurally
satisfy Expander.expand(self, ref: object, ...) under strict Pyright.
Three shipped cookbook examples work around this today with
cast("CrawlPlugin", plugin) / cast(dict[str, object], leaf_record) / a
throwaway local Protocol built purely to regain attribute access on an
object-typed callback parameter. The external ladon-hackernews adapter
carries a live # type: ignore[arg-type] on ref.raw for the same root
cause.
Separately, execute_plan_sync's on_leaf callback has always had a
different parameter contract — (leaf_record, leaf_ref) — than
run_crawl's on_leaf — (leaf_record, parent_record) — a distinction
ADR-016 established but that only a docstring sentence enforces today. A
caller can pass either shape to either function and get a plausible-looking
object, object signature match with no error until runtime.
Decision Drivers
- Eliminate the three shipped
cast(...)call sites without narrowing any concrete backend or adapter capability (the same bar ADR-014 already met). - Make the
run_crawlvsexecute_plan_syncon_leafcontract mismatch a compile-time error, not a docstring warning. - Preserve every existing bare (unsubscripted)
Ref,Expansion,RunResult,CrawlPlan,PluginRunResultusage — this repo's strict Pyright config makesreportMissingTypeArgumenta hard error for any unbound generic, so this is not optional. - Preserve
expanders: Sequence[Expander]'s existing homogeneous-list shape — third-party adapters (e.g.ladon-hackernews) already implement it and must not be forced to restructure. - Follow the structural-Protocol pattern this repo already uses for
Source/Expander/Sink, the same pattern ADR-014 cited as precedent for its own client protocols.
Considered Options
- A — Generic Protocols with per-variance-role TypeVars, defaults via
typing_extensions.TypeVar(chosen) - B — Keep
objecteverywhere; rely oncast()at every adapter boundary (status quo) - C — Fully variadic per-expansion-level typing (
TypeVarTuple-based) - D — Concrete unions at every boundary
Decision Outcome
Chosen: Option A.
Ref[RawT] and Expansion[RecordT, ChildRawT] become Generic, covariant
— the same variance already used for the one existing generic in this
codebase, Result[T, E] (networking/types.py). Both use
typing_extensions.TypeVar(..., default=...) (the PEP 696 backport; this
repo supports Python >=3.11, and native TypeVar defaults require 3.13+)
so that every existing bare usage keeps compiling unchanged: RawT
defaults to Mapping[str, object], matching Ref.raw's current field
type exactly.
Source[RefT], Expander[RefT, RecordT, ChildRawT], and
Sink[RefT, RecordT] each get one TypeVar per variance role — never a
TypeVar reused across roles, which strict Pyright's
reportInvalidTypeVarUse rejects for Protocol subclasses.
CrawlPlugin[TopRefT, LeafRefT, LeafRecordT] takes exactly three type
parameters — not one per pipeline stage. expanders: Sequence[Expander]
is a single, homogeneous list covering an unbounded, heterogeneous chain:
there is no way to express "expander i consumes what expander i-1
produced" without variadic generics that would force a fixed maximum
depth and break every existing adapter's expanders shape, including
ladon-hackernews's. expanders therefore stays
Sequence[Expander[Any, Any, Any]] — one explicit, documented escape
hatch for the chain interior. Only source and sink — single properties
with single, fully knowable signatures — carry real type precision through
CrawlPlugin.
CrawlPlan[LeafRefT] becomes generic: leaf refs have one unambiguous
source, Sink's own ref parameter type. RunResult and
PluginRunResult stay object-typed for .record/.top_refs — the
top record has no equivalently clean single-boundary source, for the
same chain-interior reason expanders does not.
Two new callback type aliases replace the bare
Callable[[object, object], ...] signatures and encode the ADR-016
distinction in the type system:
OnLeafCallback[RecordT, ParentT]— used byrun_crawl,run_plugin, and their async equivalents.ParentTis pinned toobject:run_crawlthreadsparent_recordacrossplugin.expanders, which is itselfAny-typed at the chain interior, so there is no type-safe source for a preciseParentThere.OnPlannedLeafCallback[RecordT, LeafRefT]— used byexecute_plan_syncandexecute_plan, whose callback parameter is renamed fromon_leaftoon_planned_leaf.LeafRefTis fully precise, sourced fromCrawlPlan[LeafRefT].
Passing an OnPlannedLeafCallback-shaped function to run_crawl's
on_leaf= is now a Pyright error instead of a silent runtime mismatch.
The reverse — passing an OnLeafCallback-shaped function to
execute_plan's on_planned_leaf= — still type-checks, because
on_leaf's parent_record: object parameter stays assignable under
ordinary parameter contravariance; only the narrow-to-broad direction is
caught. This is nonetheless the strongest concrete case for the change:
before it, nothing but a docstring sentence prevented either mistake.
persistence.protocol.Repository[T] genericization is explicitly out of
scope for this ADR. Its docstring already points at ADR-006, a distinct
design context whose generic value depends on adapter-specific persistence
wiring the runner does not control; conflating it with the SES boundary
here would blur the review scope of both. A follow-up issue tracks it
separately.
Consequences
- Good: the three shipped example
cast(...)call sites are eliminated. - Good: passing an
execute_plan-shaped callback torun_crawl'son_leaf=becomes a compile-time error — the direction most likely to matter in practice, sinceexecute_planis the newer, narrower contract. - Good: zero runtime behavior change —
Protocolisinstance()checks and concrete method dispatch are unaffected by type parameters, the same caveat ADR-014 already documents for its own protocols. - Good: existing untyped third-party adapters (e.g.
ladon-hackernews) need no code changes —object-typed methods trivially satisfy any generic binding, and Pyright simply infersobjectat the call site, matching today's behavior exactly. - Neutral:
expandersand the runner's own Phase-1 loop body stayAny/object-typed internally — only the plugin'ssource/sinkboundary and each adapter class's own method signatures gain precision. This is a deliberate, bounded scope. - Bad:
execute_plan_sync's andexecute_plan'son_leaf=keyword argument is renamed toon_planned_leaf=— a real, if narrow, breaking change for any caller using the keyword form. - Bad: adds a new direct dependency,
typing_extensions>=4.6(needed forTypeVardefaults), already present transitively today.
Rejected Options
B — status quo: leaves the three shipped example casts and the
external ladon-hackernews # type: ignore[arg-type] uncorrected; the
on_leaf/on_planned_leaf confusion stays a docstring-only convention
with no compiler backing.
C — fully variadic per-level typing: would require TypeVarTuple/
Unpack threading one type per expansion depth, with no clean way to
express "arbitrary but unknown length" short of a fixed maximum depth or a
redesigned expanders property shape — breaking every existing adapter,
including third-party ones. Rejected for the same reason ADR-014 rejected
its own Option B (shared ABC): disproportionately invasive for the
benefit.
D — concrete unions at every boundary: the same rejection ADR-014 already gave for its own Option C — duplicates adapter-shape knowledge into every public signature and requires every consumer annotation to change whenever a new adapter shape appears, defeating the purpose of the Source/Expander/Sink pipeline.