Skip to content

ports

Protocols for the two things this project talks to that it does not own.

Both are structural: an implementation satisfies one by having the right methods, with no base class to inherit and no registration. Anything matching these shapes works -- a local Ollama, a vLLM server, llama.cpp, LM Studio, a hosted API, or a deterministic fake in a test.

These are narrow on purpose. There is no repository protocol here. An earlier design proposed three, with in-memory fakes and a contract suite to keep them honest; two reviews argued that a repository whose method count tracks its call-site count is a rename rather than an abstraction, and the argument held. See docs/superpowers/specs/2026-08-21-onion-refactor-design.md (superseded) for the full reasoning. A protocol earns its place when a second implementation genuinely exists. For chat and embeddings it does -- the whole point is that the backend is swappable, and the test suite already ships a second implementation of the embedder in conftest.FakeEmbedder. For SQLite it does not.

Note what is NOT abstracted: reliability policy. llm.py's docstring is explicit that retry-then-skip and cache fallback belong to callers, and rank.py:198 depends on that -- it serves a stale cached profile vector when the embedder is down and raises only when the cache is cold. A port that swallowed or retried would take that decision away from the one place with enough context to make it.

BackendUnreachable

Bases: RuntimeError

The model backend refused or never answered the socket.

Raised by callers that must stop a whole run on the condition (tagging) so the run can catch it narrowly; backend_unreachable classifies the raw transport error for callers that keep the original exception.

Here rather than in llm.py because it is the contract of any backend, and the domain must be able to name it without naming a provider.

ChatPort

Bases: Protocol

A chat backend that returns JSON conforming to a supplied schema.

Schema-bound rather than free-text because every caller here wants a small structured object -- tags, a content type, an explanation. A backend that cannot constrain output to a schema does not satisfy this port, and should not: the callers parse the result without defensive checks precisely because the schema is a contract.

chat_json(messages, schema)

Return the model's reply parsed as JSON matching schema.

Raises on transport failure rather than returning a sentinel. The caller decides whether that is fatal -- see the module docstring.

CitationPort

Bases: Protocol

A source of bibliographic records, keyed by citation key or identifier.

This one earns its place under the rule in the module docstring -- three implementations exist with genuinely different backends (a SQLite file, a text format, an HTTP API), and the resolver must treat them uniformly while recording which one answered. That recording is the point: it is what makes the offline guarantee's exception inspectable rather than merely documented.

name instance-attribute

Which reader this is. Stamped onto every Reference it returns.

network instance-attribute

Whether answering can leave the machine. See citations.Resolver.

all()

Every record this source can enumerate.

Network readers raise NotImplementedError: you cannot enumerate CrossRef. Returns an iterator rather than a list because a Zotero library of 8,000 items should not be materialised to answer "is this key present".

lookup(key)

One record by citation key or identifier, or None if absent here.

EmbedderPort

Bases: Protocol

The document/query pair the ranking path actually uses.

Deliberately distinct from EmbeddingPort: embed.py applies asymmetric prompts -- DOC_PROMPT when indexing, QUERY_PROMPT when searching -- because the model was trained that way and mixing them measurably degrades retrieval. A single embed(text) cannot express that difference, so the ranking path depends on this instead.

conftest.FakeEmbedder has satisfied this shape since before it was written down; naming it makes that a checked relationship rather than a coincidence.

embed_document(title, text)

Embed an item for storage, using the document-side prompt.

embed_query(text)

Embed a search string or persona profile, using the query-side prompt.

EmbeddingPort

Bases: Protocol

A backend that turns text into a vector.

Vectors must be stable for identical input: rank.py caches a profile vector keyed on a hash of the interests text and would otherwise serve a cache entry that no longer corresponds to what it was computed from.

A round-one review called this a one-implementation Protocol worth deleting -- nothing types a parameter against it directly, EmbedderPort below does that job for the ranking path. But test_domain_reaches_ models_only_through_ports forbids a domain module from importing attestation.llm's concrete client at all, and embed.py's Embedder wraps exactly that client (EmbeddingClient) -- this Protocol is the structural contract test_ports.py checks EmbeddingClient against, the thing that makes "any backend shaped like this works" a checked property rather than a claim. Keep it.

embed(text)

Return the embedding of text as a list of floats.

backend_unreachable(exc)

Whether this failure means the model backend is unreachable.

Matched on the transport exception rather than on message text: httpx raises ConnectError/ConnectTimeout for a refused or unanswered socket, which is exactly the "Ollama is not running" case. Shared by ingest (the embedder) and tagging (the chat model): both stop at the first such failure and say so once, instead of failing every remaining item against a dead socket.