# ADR 0005 — The configured endpoint is the catalog, and the shipped snapshot is only a fallback
Status: accepted (2026-10-03)
## Context
`0.1.0` shipped a curated catalog: the 17 models Ollama Cloud served on
2026-09-29, each with the context window, input modalities, and thinking-ladder
metadata that `POST /api/show` reported that day. Every capability the plugin
serves — including the levels the composer's Effort selector offers and the
default it materializes — was therefore a copy of one endpoint answer, frozen at
release time.
Ollama does not freeze. It adds models, retires others (`410`, with the id
sometimes still listed for a while), and changes a model's `thinking` metadata
(level sets and defaults) between releases. Two consequences followed, and both
shipped:
- **A retired model keeps being offered.** The route advertises what its catalog
says, so the picker offers a model that answers `410` at request time, and the
failure arrives as a provider error rather than as an absence.
- **New models, and re-levelled ones, need a plugin release.** `0.2.x` gained a
live listing through `llm/discoverModels`, but that surface is *advisory*: the
harness offers the answer to a configuration page for adoption, stores
nothing, and the harness's own `LlmDiscoveredModel` shape does not even carry
reasoning metadata. Nothing refreshed the route, so the practical workaround
was to re-release the plugin with a new snapshot — exactly the maintenance the
plugin exists to avoid (it happened once during `0.2.x` development for
`deepseek-v4.1-flash`).
The catalog also has to survive the two conditions a refresh cannot assume:
a boot with no reachable endpoint, and an endpoint that answers `/api/tags` but
fails a `/api/show`.
## Decision
1. **One refresh reads the endpoint and adopts the answer, at mount and on an
interval.** `live-catalog.ts` performs it: `GET /tags` for membership,
`POST /show` per model for metadata, decoded by the same
`decodeShowResponse` the advisory discovery surface uses, so a capability a
user may adopt and a capability the route serves can never disagree. The pass
is not awaited at mount (a slow endpoint must not hold up a boot), it is
throttled so two passes cannot overlap, and a failure is logged and leaves
the catalog in use exactly as it was. `autoRefresh` (default `true`) and
`refreshMinutes` (default `1440`) control it, and the scheduler re-reads both
on every tick — including the idle tick that runs while the refresh is off,
which touches no network and exists so switching it back on needs no reload.
2. **The catalog is layered, and each layer answers only what it knows.**
| Layer | Authority |
| --- | --- |
| `models:` configuration | per-id overrides and `enabled: false` retirements |
| the endpoint, just fetched | membership and every declared capability |
| the cache of the last fetch | the same answer, read before the network answers |
| the shipped snapshot | display names, and the pre-fetch fallback |
The merge policy lives in `catalog.ts` (`mergeCatalogEntry`): the endpoint
wins for every field it answered, the snapshot fills what it did not, and
*"the endpoint said nothing"* (`reasoningEfforts` omitted) is kept distinct
from *"the endpoint said no thinking"* (`reasoningEfforts: false`). A default
level is inherited only while the level set is, mirroring the rule
configuration already follows.
3. **Membership shrinks by retirement, never by silence.** `discoverCatalog`
separates three outcomes, because they mean different things: *described*
(metadata), *retired* (`404`/`410` — the endpoint stating its own listing is
stale, dropped from the catalog), and *unknown* (timeout, 5xx, 429 — the
model stays listed with only what the listing said). Without that last case a
single slow `/api/show` would hide a real model for a whole interval; without
the retirement case the picker would offer a model that cannot answer. An
**empty** listing rejects the pass entirely: a model picker with nothing in
it is not a state the UI can recover from, and "everything retired at once"
is a far less likely explanation than a gateway hiccup.
4. **The cache is what makes a restart — and an offline boot — honest.** It
lives under the harness home, resolved through the harness's own
`@deepseek-ai/dsh-home-paths` helper (`/cache/dsh-ollama-cloud/catalog.json`),
so it agrees with every other piece of harness user data about where user
data lives and is not lost when the package is reinstalled or updated; it is
written atomically (temporary file plus rename) and validated whole on read
(a malformed entry discards the file rather than serving a partially trusted
catalog). One endpoint at a time: a catalog fetched from another `baseURL` is
ignored rather than merged.
5. **Adoption reaches the running session through the revision, not a reload.**
`CatalogSource.revision()` joins the volatile references the connection
reader memoizes on (`createConnectionReader`), so adopting a changed catalog
re-resolves the route, rebuilds the adapter's profile from the same facts the
request path reads, and the model list, context windows, and effort levels
all move together. A pass that only *confirmed* the catalog does not move the
revision, so nothing rebuilds for nothing.
Rejected: **keeping the snapshot authoritative and re-releasing per change** —
that is the maintenance burden this ADR exists to remove, and it cannot react to
a retirement between releases. Rejected: **automatically calling the harness's
`llm/discoverModels` and adopting its answer into the user's settings** — that
surface is for a *draft* endpoint in a configuration page, it carries no
reasoning metadata, and writing the user's configuration from a background timer
would make the plugin's own catalog invisible to the settings layer it just
wrote. Rejected: **refreshing without a cache** — the first model list of every
boot would then race the network, and an offline boot would serve a snapshot
whose age nobody can see, which is precisely the failure mode being fixed.
Rejected: **a TTL that skips the mount refresh** — the request is one anonymous
listing plus one per model, and "current as of this boot" is the whole promise.
## Consequences
- A model Ollama adds, retires, or re-levels reaches a running session on the
next pass (at most one interval) and every session after that at mount, with
no plugin release. The shipped snapshot stays in the repository as the
offline layer and as the source of display names; it is no longer a
correctness surface, so a stale snapshot is a cosmetic issue rather than a
routing one.
- Two new configuration fields, both read live. Setting `autoRefresh: false`
stops every network refresh: the route then serves the cache of the last
fetch (or the shipped snapshot before any fetch), and the scheduler's only
remaining work is re-reading these two values on an idle cadence so switching
the refresh back on is honored without a reload. That is the honest setting
for an air-gapped or quota-limited deployment.
- The cache holds one endpoint at a time *per harness home*. Two profiles
sharing a home but pointed at different endpoints each fall back to the
shipped snapshot for the first second of a boot, until their own mount
refresh lands; nothing is corrupted, because the endpoint validates on read
and the refresh overwrites it either way.
- The cache is per-harness-home and per-endpoint; a user who switches `baseURL`
serves the snapshot until the new endpoint answers. That is deliberate — a
merged catalog across two endpoints would offer models the configured endpoint
does not have.
- `discoverCatalog` now distinguishes retirement from failure, so the advisory
`llm/discoverModels` surface inherits the same rule: a `404`/`410` entry is no
longer a candidate, while an undescribed one still is not either (adoption
needs metadata).
- The suite runs with a throwaway `DSH_HOME`: the cache made the plugin's tests
capable of writing into a developer's real catalog, and a test that adopted a
fake catalog would otherwise decide what the next real boot serves.