# ADR-009: Per-`focus_area` Batch Granularity for the Findings Cache

## Status

Accepted (2026-04-28)

## Context

[ADR-006](ADR-006-cache-port-incremental-analysis.md) committed Spectra to an incremental analysis cache and chose **Option C — per-`focus_area` batches** as the granularity for the killer feature. ADR-006 made the architectural commitment; what was not yet pinned down was the *implementation surface* that would make per-batch caching robust enough to ship.

Three concrete decisions had to be made before Phase 3 could land safely. Each of them is small individually but, taken together, define the contract between the use-case layer and the cache adapter for the next several years:

1. **Cache key shape.** A per-batch row needs more than `(batch_id, dimension)`. The full key must capture every signal that should invalidate findings — model identity, prompt identity, schema shape, spectra version. Picking the right tuple now avoids a table migration later.
2. **Run-context binding.** Phase 1 used per-method setters (`set_model_version`, `set_prompt_version`, `set_schema_version`). That left an intermediate-inconsistent-state failure mode: a bug or partial wire-up could leave the cache reading rows under one prompt version and writing them under another. We needed an atomic four-tuple binding.
3. **Telemetry surface.** "Did the cache help?" is the load-bearing question. We needed to wire hit/miss telemetry that surfaces in the terminal during ANALYZE *and* feeds an aggregate metric exposed via `spectra cache stats`.

A fourth question — when to fall back to the Phase 2 behavior (one batch per dimension) — also had to be settled, because not every plan from MetaPrompter cleanly emits `focus_areas`.

## Decision

Three commitments that together implement the Phase 3 surface promised by ADR-006:

### 1. Composite `BatchCacheKey` with six fields

Phase 3 introduces a frozen Pydantic key:

```python
class BatchCacheKey(BaseModel, frozen=True):
    batch_id: str          # blake2b(sorted file_hashes) — deterministic per focus_area
    dimension: Dimension
    model_version: str
    prompt_version: str    # blake2b(specialist_prompt + shared + critique) — auto-derived
    schema_version: str    # SchemaVersion literal — bumped when Finding shape changes
    spectra_version: str   # spectra.__version__ — included so a release bump invalidates everything
```

The new table `findings_batches` uses all six fields as its composite primary key. A row is reused only if every field matches the current run's context. Bumping any of them naturally misses the cache without touching disk — invalidation is implicit, not policy.

We deliberately picked `prompt_version = blake2b(prompt_text)` over a manually-bumped constant per dimension. This eliminates a class of human-error bugs (prompt edits without version bumps) at the cost that whitespace tweaks invalidate the entire cache for that dimension. We accept that tradeoff — a wasted re-analysis is far cheaper than serving findings that were generated by a stale prompt.

### 2. Atomic `bind_run_context(model, prompt, schema, spectra)`

Replace the four Phase 1 setters with one method:

```python
def bind_run_context(
    self,
    model_versions: str,
    prompt_versions: str,
    schema_version: str,
    spectra_version: str,
) -> None:
    """Atomically bind the four versions used by every Phase 3 cache key."""
```

The composition root calls this exactly once at startup. Subsequent `batch_key_for(batch_id, dimension)` calls inherit the four versions atomically — there is no window where the adapter holds a half-bound context. Tests exercise this contract by asserting `batch_key_for(...) is None` when `bind_run_context` has not been called, which short-circuits per-batch caching for that run rather than using a partially-bound key.

### 3. Hit-log telemetry: `record_hit` + `on_cache_lookup`

Two layers of telemetry:

- **Adapter-level.** `record_hit(dimension, batch_id, hit: bool)` is called on every `get_batch_findings` lookup. It appends a row to the `hit_log` table. `CacheStats.hit_rate_last_100` reads `SELECT hit FROM hit_log ORDER BY ts DESC LIMIT 100` and computes the rolling rate.
- **Pipeline-level.** `ProgressObserver.on_cache_lookup(dimension, hits, total)` is called once per dimension after `partition_by_cache` runs. The terminal sees a per-dimension tally — e.g. `security cache 7/8 hits`.

The Phase 4 CLI subcommand `spectra cache stats` (shipped in PR #19) surfaces `CacheStats` to the user as the operational signal for "is the cache working?".

### 4. Fallback policy when no `focus_areas`

Some plans from MetaPrompter come back without `focus_areas` (sparse plans, edge cases on tiny repos). When that happens, `build_batch_prompts` falls back to **one batch per dimension** — the Phase 2 behavior. The cache still works (it just operates at coarser granularity for that run), and there is no code path where a missing focus area aborts the analysis.

## Consequences

### Positive

- **Implicit invalidation.** Every signal that should invalidate a row is part of the composite key. A model bump, prompt edit, schema change, or release version bump naturally misses the cache without any "if any version changed, purge X" code.
- **Atomic context binding.** One call site, one consistent state. Eliminates a category of bugs where the adapter could read under one prompt version and write under another.
- **Hit-rate becomes a first-class signal.** Both per-dimension (terminal during ANALYZE) and rolling (`spectra cache stats`). Without this telemetry the cache is a black box; with it, the killer feature is visible on every run.
- **Auto-derived `prompt_version`.** Eliminates the "I edited the prompt and forgot to bump the constant" failure mode. Whitespace tweaks invalidating the cache is a feature: it's a guarantee that what we serve was generated by exactly the prompt we are running.
- **Sparse-plan fallback is silent.** No user-facing degradation when MetaPrompter doesn't emit focus areas — the cache still helps, just at coarser granularity.

### Negative

- **Six-field PK on `findings_batches`.** Every read and write packs a six-tuple. The query plan is fine (PRIMARY KEY index), but the SQL string is verbose and bug-prone — we addressed this with `_batch_key_params` and `_batch_upsert_params` helpers that pack the tuple in one place.
- **Whitespace-only prompt edits invalidate the cache.** An accepted tradeoff; the alternative (manual version bumps) had a worse failure mode.
- **Two writes per fresh batch.** `put_batch_findings` then `record_hit(..., hit=False)`. Negligible at our row sizes but worth noting for future bulk-import scenarios.
- **`run_versions` held on the adapter is process-state.** A long-lived adapter shared between two tests must call `bind_run_context` again before each run. We cover this in the test fixtures.

### Neutral

- The Phase 1 `findings_cache` table stays in place even though `findings_batches` is the active read path for Phase 3. Phase 1 is the foundation; deleting it would require a separate migration and gains nothing. `spectra cache prune` (Phase 4) treats both tables uniformly.
- `record_hit` currently writes a rolled-up `(ts, hit)` row. Phase 4 will add `dimension` and `batch_id` columns to support per-dimension hit-rate breakdowns. The migration is additive — old rows remain valid.

## Alternatives Considered

| Alternative | Verdict |
|-------------|---------|
| **Smaller key: `(batch_id, dimension, prompt_version)` only.** | Rejected. A model bump or schema bump must invalidate cached findings. Including model + schema + spectra in the key is the cheapest correct option; omitting them moves the invalidation burden onto explicit code. |
| **Per-dimension manual `PROMPT_VERSION = "v3"` constants.** | Rejected. Easy to forget to bump after a prompt edit, and the failure mode (serving findings generated by a different prompt) is silent. Auto-derived from `blake2b(prompt_text)` is strictly better. |
| **Keep the four Phase 1 setters; add a check in `batch_key_for`.** | Rejected. Adds a runtime check that does not eliminate the underlying race. The atomic four-tuple is simpler to reason about and impossible to leave half-bound. |
| **No telemetry; ship Phase 3 first, add metrics later.** | Rejected. Without `on_cache_lookup` we cannot answer "is the cache working?" during the launch window. The telemetry cost is ~5 lines of code. |
| **Per-batch hit log with full dimension/batch_id columns from day one.** | Rejected for Phase 3, deferred to Phase 4. The rolled-up `(ts, hit)` form is enough for `hit_rate_last_100`; the richer schema lands when the CLI surfaces it. |
| **Always use the Phase 1 per-file granularity (no batch concept).** | Rejected. ADR-006 already settled this — per-file means ~9K LLM calls on a 1.5K-file cold run. Per-`focus_area` batches preserve intra-batch context at much lower call volume. |
| **Read from `findings_batches` first, fall back to `findings_cache`.** | Rejected for v1. Two read paths per lookup is twice the I/O for marginal benefit. Migration from Phase 1 rows to Phase 3 rows happens naturally as the cache fills on subsequent runs. |

## References

- Code: `src/spectra/use_cases/interfaces.py:242-340` — `CachePort` Protocol, including `bind_run_context`, `batch_key_for`, `record_hit`, `get/put_batch_findings`
- Code: `src/spectra/entities/models.py:373-419` — `BatchPrompt`, `BatchCacheKey`
- Code: `src/spectra/infrastructure/cache_adapter.py:96-115` — `_CREATE_BATCH_FINDINGS_TABLE` schema with the six-field PK
- Code: `src/spectra/infrastructure/cache_adapter.py:240-276` — `bind_run_context` and `batch_key_for`
- Code: `src/spectra/infrastructure/cache_adapter.py:418-431` — `record_hit` write path
- Code: `src/spectra/use_cases/analyze_repository.py:347-468` — `_run_analyze_stage_with_cache`, `_split_batches_by_cache`, `_persist_fresh_batches`, `_assemble_phase3_result`
- Code: `src/spectra/use_cases/analyze_repository.py:494-523` — `build_batch_prompts` with the no-`focus_areas` fallback
- Design doc: [`docs/plans/incremental-analysis.md`](../../plans/incremental-analysis.md) §4 (Option C) and §5 (Phase 3)
- Related: [ADR-006](ADR-006-cache-port-incremental-analysis.md) — the architectural commitment this ADR implements
- Related: [ADR-005](ADR-005-opus-4-7-migration.md) — `model_version = "claude-opus-4-7"` is the current value bound by `bind_run_context`

---

*Last updated: 2026-04-29.*
