Hedronite · Dev Synthesis Lesson · Polyglot-Dev / Python · Track Python Day 11 · Sun 2026-08-02 · Bundle Trio #3

Python's Typing in Depth — the contract without the registry

The checker reads the promise. The runtime never does.

Lesson Class: Dev (Protocols · structural subtyping · PEP 695 generics · ParamSpec)
Track / Day: Python (Deep Python) — round-robin day 11, visit 4; pure-language slot
Word Count: ~1,600 (code-heavy)
Grounding: Fluent Python 2nd — Ch 8 Static Protocols (pp. 316-317) · Ch 13 Runtime Checkable (pp. 498-499) · Programming Ducks (p. 465) · Generic Mappings (p. 307)
Paired Ops: Observable Python Automation — the Run and Its Witnesses
Paired Cert: AWS SAP — The Multi-Account Landing Zone
Discipline: ROD v3 · py-blue code borders · bundle shape (Maghrib fills quiz; lab-ref skipped on Python-day non-Cert)
A consumer calling your function next month will not reread the body. The contract lives at the boundary, or nowhere.

§ IFrame — The Rung After Packaging

Four rungs, one arc. 07-24 gave the error model its depth. 07-27 gave concurrency its structure. 07-30 gave the code a shippable shape, and shipping created a new problem on purpose: the library now has consumers who are not its author. The contract has to live at the boundary, and a machine has to enforce it, because humans reviewing diffs enforce nothing at 06:30.

Python's answer is gradual typing: annotations the runtime ignores and a checker (mypy, pyright) verifies before anything runs. Ramalho is blunt about the division of labor, and the bluntness is the insight: hints exist for the checker; at runtime they are documentation at best (Fluent Python, ch. 8). The entire payoff arrives before the program starts, which is precisely where the Ops lesson's unattended run wants its guarantees.

The plan: structural contracts with Protocol, parameterized ones with PEP 695 generics, decorator boundaries with ParamSpec, all worked against the witness module today's Ops lesson built.

§ IILanguage Idiom — The Contract Without the Registry

Python has always run on duck typing: call the method, and if it quacks, it quacks. Ramalho's Programming Ducks chapter names the cost: the duck is discovered at runtime, in production, at the call site, possibly at 06:31 (ch. 13, p. 465). The nominal alternative, abstract base classes, moves the check earlier but demands enrollment: a class is a Sink because it inherits or registers. Somebody keeps a registry.

typing.Protocol is the third way: the contract without the registry. A Protocol declares a shape; anything with that shape conforms. No inheritance, no registration, no import of the Protocol by the conforming class (Static Protocols, ch. 8, pp. 316-317):

from typing import Protocol

class Sink(Protocol):
    def emit(self, line: str) -> None: ...

Every sink the Ops lesson mentioned already conforms. The stdout sink wraps a stream handler; the file sink appends JSON Lines; the test suite's in-memory sink is a list with an emit method. None of the three imports Sink. The library's public functions now say what they need instead of what they know:

def flush(records: list[str], out: Sink) -> None:
    for line in records:
        out.emit(line)

Static by default, a Protocol can opt into runtime checks with @runtime_checkable, making isinstance(obj, Sink) legal. Ramalho flags the trap worth carrying: the runtime check is shallow, verifying the method exists, never its signature (ch. 13, pp. 498-499). Use the runtime check as a gate at system edges where objects arrive untyped; let the static checker hold the full contract everywhere else.

Two idioms complete the toolkit. Keep Protocols narrow, one or two methods, defined where they are used; a fat Protocol recreates the ABC it was escaping. And annotate boundaries loose-in, strict-out: accept the narrowest Protocol that covers what the function touches, return the most concrete type you build.

§ IIICode Worked Example — Typing the witness Module

The Ops ledger holds rows. Rows of what? Yesterday a host result, tomorrow a publish result. Containers are where generics earn their keep (Generic Mappings, ch. 8, p. 307), and Python 3.12's PEP 695 syntax makes the declaration read like the idea:

from dataclasses import dataclass, field

@dataclass
class Row:
    key: str
    grade: str
    detail: str = ""

@dataclass
class Ledger[T]:
    rows: list[T] = field(default_factory=list)

    def add(self, row: T) -> None:
        self.rows.append(row)

    def where(self, pred) -> "Ledger[T]":
        keep = [r for r in self.rows if pred(r)]
        return Ledger(rows=keep)

Before 3.12 that class needed a module-level TypeVar("T") and a Generic[T] base; the type parameter now lives in the class header, scoped to the class, invisible to the runtime. Ledger[Row] and Ledger[PublishResult] are one class object at runtime and two different promises to the checker: one implementation, many contracts.

Functions parameterize the same way. The 07-27 sweep wants "first fatal row, whatever a row is here":

def first[T](items: list[T], pred) -> T | None:
    for item in items:
        if pred(item):
            return item
    return None

The return type T | None is the 07-24 discipline resurfacing in type-space: the absence case sits in the signature, so every caller confronts the miss before touching the hit.

The boundary that erases types most often in ops code is the decorator, and the arc has carried one since 07-24: retry. Wrapped naively it returns Callable[..., Any] and every signature it guards dissolves. ParamSpec carries the full parameter list through the wrapper; with PEP 695 it is one bracket:

import functools, time
from collections.abc import Callable

def retry[**P, R](attempts: int, delay: float):
    def deco(fn: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(fn)
        def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
            last: Exception | None = None
            for _ in range(attempts):
                try:
                    return fn(*args, **kwargs)
                except TransientError as exc:
                    last = exc
                    time.sleep(delay)
            raise RetryBudgetExhausted(attempts) from last
        return wrapped
    return deco

The decorated fetch(host: str, timeout: float) -> Payload keeps its exact signature. Note the body honoring 07-24 on the way through: raise ... from last preserves the cause chain inside a fully typed wrapper. The exception chain preserves the story at runtime; the ParamSpec preserves the contract before it. Run mypy --strict and this vocabulary becomes a CI gate in the 07-30 package: the checker is just another witness, one that testifies before the run instead of during it.

§ IVConnection to Today's Ops Lesson

The Ops lesson closed §VI asking what a sink is to the checker; this lesson answered with eleven lines. Item by item: the three sinks conform to one two-line Protocol, so tests substitute theirs with no monkeypatching. The partial-failure ledger becomes Ledger[Row] and the publish pipeline reuses it as Ledger[PublishResult] untouched. The retry decorator stops erasing the signatures the library published at 07-30. And the heartbeat writer's outcome tightens from str to Literal["ok", "fail"]: the checker now refuses the typo a 06:30 run would have written into a heartbeat nobody validates.

§ VPrior-Lesson Reach

To 07-30: packaging fixed the import addresses and the public API; typing is the second half of that contract, and mypy --strict in CI is the enforcement pyproject could not express. To 07-27: TaskGroup fan-outs return heterogeneous results, and Ledger[T] plus except* are the typed pair that grades them. To 07-24: the exception hierarchy was the error API; Protocols are the capability API; both are contracts consumers lean on without reading bodies, one raised at runtime, one read by the checker.

§ VIClosing

Type hints change nothing at runtime, which is why they change everything before it. Declare narrow Protocols where they are used. Parameterize containers with PEP 695 brackets. Thread ParamSpec through every decorator that guards a published function. Then point mypy --strict at the package and read what it says slowly: every complaint is a conversation the library was going to have with a consumer at 06:30, moved to noon the day before.

Type one real module of your own this week. The first hour is annotation; the second is discovering what the code actually promised.

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-02 Fajr · Dev lesson · Python deep-mastery track (day 11, visit 4; pure-language slot)