# ADR-0024: TTL-Bounded In-Memory Cache for Near-Static JIRA Catalogues

## Status

✅ Accepted (2026-05-10)

## Context

The MCP server exposes four tools whose underlying JIRA endpoints return
catalogue data that changes only when an administrator edits an instance-wide
setting:

| MCP tool             | Underlying endpoint                  | Typical change frequency |
|----------------------|--------------------------------------|--------------------------|
| `jira_link_types`    | `GET /rest/api/3/issueLinkType`      | rarely (admin-only)      |
| `jira_field_list`    | `GET /rest/api/3/field`              | rarely (admin-only)      |
| `jira_project_list`  | `GET /rest/api/3/project/search`     | weeks–months             |
| `jira_board_list`    | `GET /rest/agile/1.0/board`          | weeks–months             |

Before this ADR, every invocation reconstructed an `AtlassianClient` from
on-disk credentials (`create_client` in
[`src/cli/atlassian/helpers.rs`](../../src/cli/atlassian/helpers.rs)) and
issued a fresh HTTP request. An assistant browsing link types or fields
during an authoring session can easily call these tools five or ten times in
a few seconds — each call does the same disk read, the same handshake, and
the same paginated walk.

Searching for any existing in-memory cache primitive in the project
(`OnceCell`, `LazyLock<RwLock<…>>`, `dashmap`, `moka`, `cached`) found only
static `LazyLock<…>` uses for *compiled regexes* and the new ADR-0023 ADF
content-model lookup table. No infrastructure cached *runtime API responses*.
This ADR records the first such cache.

[Issue #719](https://github.com/rust-works/omni-dev/issues/719) asked
whether the link-types catalogue specifically should be cached and noted
that the same question applies to fields, projects, and boards.

## Decision

Introduce a TTL-bounded, in-memory cache for the four catalogues, scoped to
the MCP server process.

### Placement

A new module
[`src/mcp/catalogue_cache.rs`](../../src/mcp/catalogue_cache.rs) defines
`CatalogueCache`, owned by `OmniDevServer` as
`Arc<CatalogueCache>`:

```rust
pub struct OmniDevServer {
    pub tool_router: ToolRouter<Self>,
    pub catalogue_cache: Arc<CatalogueCache>,
}
```

`Arc` keeps the existing `#[derive(Clone)]` cheap — rmcp clones the handler
per request. The cache lives for the process lifetime; a server restart
flushes it.

CLI commands (`omni-dev atlassian jira link types`, etc.) are *not* wired
through the cache. They are one-shot per process, so caching has nothing to
amortise.

### TTL

One hour, hard-coded as `catalogue_cache::DEFAULT_TTL`. Rationale:

- Long enough to cover any plausible burst of assistant activity within a
  single editing session (the dominant use case).
- Short enough that an admin changing link types in JIRA UI sees the change
  reflected within an hour without manual intervention.
- Tests construct caches with arbitrary `Duration`s via
  `CatalogueCache::new(ttl)` for deterministic expiry behaviour.

A configurable TTL (env var or CLI flag) is deferred until evidence of need.

### Single-flight via `RwLock`

Each catalogue has its own `tokio::sync::RwLock<Option<CacheEntry<T>>>`.
Lookups use a read fast path; on miss or expiry they upgrade to a write
lock, double-check (in case a concurrent waiter already populated the slot),
and only then fetch. Concurrent readers see the populated value on the
re-check. No extra mutex or `OnceCell` is required — the write lock is the
single-flight gate.

### Instance-URL keying

Each cached entry records `instance_url` of the `AtlassianClient` that
produced it. A subsequent lookup whose client points to a different
instance treats the entry as stale and refetches. The cache holds at most
one entry per catalogue at a time; multi-instance use within a single
process refetches on every switch but never serves the wrong instance's
data. This is acceptable because the dominant use case is one assistant
session against one JIRA instance.

Credentials are *not* part of the key. Catalogue data (link types, field
definitions, projects, boards) is global to the JIRA instance and the same
for every authenticated user; re-auth within a process should not invalidate
the cache.

### Caching parameterised methods

`get_projects(limit)` and `get_boards(project, board_type, limit)` accept
filters/limits applied *server-side*. To make the cache hit rate independent
of caller arguments, the cache always fetches the **unfiltered, unbounded**
result (`get_projects(0)` and `get_boards(None, None, 0)`). The YAML helper
layer applies the requested limit/filter to the cached `Arc<…>` at serve
time. This is correct because:

- `get_projects` paginates server-side; `limit=0` returns every project on
  the instance (admins typically have well under 100).
- `get_boards` filters are equality on `project_key` and `board_type`, both
  already present on the cached `AgileBoard` struct.

The trade-off: the first call after expiry always fetches the full
catalogue. Acceptable given the catalogues are small and the cache amortises
the cost across all subsequent calls.

### Errors do not poison the cache

A failed fetch propagates the error and leaves the slot untouched (still
empty, or still holding the previous successful entry). Subsequent calls
retry. This avoids two failure modes — locking out the cache forever after
one transient 500, and silently masking permanent errors with a stale entry.

## Consequences

### Positive

- Repeated catalogue lookups within a session collapse to a single HTTP
  request per catalogue per hour.
- The four catalogue tools' MCP handlers shed direct knowledge of HTTP — the
  cache is the seam between the protocol layer and the network layer.
- Future catalogue tools can opt in by adding a method to `CatalogueCache`
  that follows the same `get_or_fetch` pattern.

### Negative

- First call after expiry pays the full-fetch cost even if the caller only
  asked for `limit=10`. Mitigation: catalogues are small.
- An admin editing JIRA configuration sees stale results for up to one
  hour. Mitigation: documented TTL; restart flushes; manual invalidation
  tool deferred to a follow-up if pain materialises.
- `OmniDevServer` is no longer a pure router — it now carries shared state.
  The `Arc<CatalogueCache>` field is the only such state today; if more
  shared state appears, factor a `ServerState` struct.

### Out of scope

- A `jira_cache_clear` MCP tool for manual invalidation. Defer until a user
  reports the staleness window biting them.
- Caching for non-catalogue tools (`jira_field_options`, `jira_read`, full
  searches). Their inputs vary too widely for a useful hit rate.
- Caching for CLI invocations. One-shot per process; nothing to amortise.
- Configurable TTL via env var or CLI flag. Defer until evidence we need
  to tune it.

## Alternatives considered

- **Module-level `LazyLock<RwLock<…>>` static.** Less code, but global
  state is harder to reset between tests and hides ownership. Rejected in
  favour of an explicit field on `OmniDevServer`.
- **Single global cache with composite keys** (e.g., `HashMap<(instance_url,
  catalogue), Arc<dyn Any>>`). More flexible, but the four catalogues have
  enough type-level differences that separate `RwLock`-wrapped slots are
  clearer and avoid `dyn Any` downcasts.
- **Cache the YAML output** rather than the parsed `Arc<Vec<…>>`. Cheaper
  on cache hits but defeats client-side filtering for `field_list_yaml`,
  `project_list_yaml`, and `board_list_yaml`, which all need to slice or
  filter the full catalogue per request.
- **Process-lifetime cache (no TTL).** Simplest, but admins editing
  catalogues during an assistant session would never see the change without
  restarting the MCP server. Rejected as too sticky.
