Ports and adapters
A port is an interface the application layer declares; an adapter is a concrete implementation of it. The core depends only on ports, which is why it can be tested with fakes and deployed with two different runtimes.
The ports
Section titled “The ports”| Port | Contract | Production adapters |
|---|---|---|
Collector |
collect(source) → Candidate[] |
rss, webFetch (page), tavily (search) |
Evaluator |
evaluate(items, condition, lastKnownState?, options?) → EvaluationResult |
cascade over key-rotating LLM adapters |
Embedder |
embed(text) → number[] |
key-rotating LLM embedder |
Notifier |
notify(notification), plus a channel |
telegram, fcm, console |
Mailer |
send(message) — account email only |
nodemailer, console |
WatchDrafter |
draft, revise, explain |
key-rotating LLM drafter |
DomainChecker |
check(domains) → Record<domain, boolean> |
HTTP HEAD checker |
WatchSource |
loadWatches() |
YAML files (import only) |
Repository |
Watches, run state, history, feedback, trust rules, devices, keys, usage, users | mongo, memory |
Contracts worth stating explicitly
Section titled “Contracts worth stating explicitly”Collector — returns clean text items, each with a URL and a fetch time. It may throw; the loop tolerates a failing source and continues with the others. It should validate its own output and drop malformed items rather than failing the whole source.
Evaluator — returns a verdict, or throws. It must not apply the threshold:
the decision belongs to the pure core. The options argument carries per-watch
governance, notably whether a secondary model may see this content.
Embedder — best-effort from the loop’s perspective. A failure disables semantic deduplication for that run rather than failing the run.
Notifier — throws on failure. The loop treats a channel failure as tolerable if another channel succeeded, and queues the alert if none did.
Repository — the widest port, and the one to be most careful with. Every optional field of run state must be handled in both directions.
Composition
Section titled “Composition”Several adapters wrap others rather than talking to a provider directly:
CascadeEvaluator second opinion + failover └── KeyRotatingEvaluator picks a key, rotates on rate limits └── LlmEvaluator prompt, chunking, structured outputThe same pattern applies to the embedder and the drafter. Each layer does one thing, and each can be tested in isolation.
Every port has a fake used by the tests: canned collectors, scripted evaluators and embedders, an in-memory notifier that records what it was asked to send, and a full in-memory repository. This is why the suite needs no network and no keys — and it is the reason a new adapter should come with a fake if it introduces a new port.
Wiring
Section titled “Wiring”entrypoints/wiring.ts is the single place the real graph is assembled: key
pools per use case, collectors sharing one pooled search client, the cascade
evaluator, notifiers with console fallbacks, and the shared per-watch lock. Both
runtimes call it.
To add an adapter, implement the port, add a test with a fake transport, and
register it in the wiring. Nothing in domain/ or application/ changes.
Adding a new port
Section titled “Adding a new port”Rare, and worth resisting. Before adding one, check whether the capability fits an existing port. If it genuinely does not:
- Declare the interface in
application/ports.tswith a documented contract. - Accept it as an optional dependency in the loop, so existing callers still work.
- Provide a fake alongside the real adapter.
- Wire it in one place.