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.

BackendNotConfigured

Bases: BackendUnreachable

There is no model target to call: the host asked for the one Hermes is connected to and Hermes has none (or its credentials need renewing, or its runtime cannot be found).

A kind of BackendUnreachable so every run that already stops once on a dead backend stops once on this too, with the message it carries -- instead of asking the Ollama default for a model it never had.

BackendRejected

Bases: BackendUnreachable

A model backend that WAS reached refused the request for a reason that holds for every request (401/403 key, 404 no such model, 410 retired).

Raised by clients that do not speak httpx themselves (the Hermes bridge), so the domain's classifiers treat it exactly like the HTTP error a direct client would have raised. status is the HTTP status, 0 when unknown.

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_rejected(exc)

Whether a REACHABLE backend refused the request for a reason that holds for every request: 401/403 (key), 404 (no such model or path), 410 (end of life). Retrying per item cannot help, so a run stops once and says why.

400 and 422 are NOT here: those can be one item's problem (a schema the server cannot satisfy), and the tagger's own retry owns them.

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.

display_url(url)

A URL safe to print: scheme, host, port and path -- no userinfo, no query.

embedding_backend_hint()

Where the embedder's URL came from, for the "embedding model unreachable" messages of the domain modules (which may not import attestation.llm).

Reads the same two variables llm.embed_base_url does and says which one won, or that neither is set and the built-in Ollama default applies -- the old text named the variable but called an unset one "unset" and left the reader to find out that meant Ollama on port 11434.

failure_detail(exc)

One line on why a backend call failed, safe to print.

Never str(exc) for an HTTP error: that carries the full request URL. The status and the server's own reason are the useful part (a 404 from Ollama reads model 'x' not found), and neither can contain a credential.

host_is_loopback(url)

Whether the URL's host is this machine (localhost, 127.0.0.0/8, ::1).

key_is_header_safe(value)

Whether value can be an HTTP header value: printable ASCII, no spaces or newlines. A key that is not fails every request in a way that names nothing.

redact(text)

text with every registered secret and every credential-shaped string (Bearer values, x-api-key, sk-/nvapi- keys, JWTs) replaced by ***.

register_secret(value)

Remember a credential so redact removes it from every message, whatever shape it has. Called wherever a key is resolved; nothing is ever printed.