## Extensibility

> Note: every original v1.29.0 finding (EX-V1.29-1 through EX-V1.29-9, archived in `docs/audit-triage-v1.30.0.md`) was substantially closed by #1101 (Local LLM provider), #1105 (slots/cache), #1114 (single-registration command registry), the `define_aux_presets!` table, the `define_chunk_types!` macro, the `define_query_categories!` macro, the `ConfigSection` trait + `ArgMatches::value_source()` shift, the `VisibilityRule::{SigStartsTriage, RegexImportSet, NameCase}` extensions, the `NotesListArgs` flatten, and the `approx_download_bytes` field on `ModelConfig`. The findings below are the residual / new pressure points after that wave.

#### Adding a third score signal requires touching two parallel fusion code paths
- **Difficulty:** medium
- **Location:** `src/store/search.rs:182-229` (`Store::rrf_fuse(semantic_ids: &[&str], fts_ids: &[String], limit) -> Vec<(String, f32)>`), `src/search/query.rs:511-720` (`search_hybrid` linear-α dense+sparse), `src/search/query.rs:399` (call site uses 2-list `rrf_fuse`)
- **Description:** Today there are two completely separate ways to combine ranked candidate lists into a final score:
  1. `Store::rrf_fuse(semantic_ids, fts_ids, limit)` — RRF formula `1/(k+rank)`, hardcoded to **exactly two** input lists (semantic embedding + FTS keyword). The function signature has named `semantic_ids` and `fts_ids` parameters; adding a third signal (a name-fingerprint signal, a type-overlap signal, the SPLADE sparse list, or the cross-encoder rerank score) requires changing the signature plus every caller.
  2. `Self::search_hybrid` — a separate dense+sparse linear-interpolation path with its own α knob (`splade_alpha`), its own min-max normalization, its own dedup loop. SPLADE blends here, not via RRF, because RRF is locked to two lists.
  
  Concrete symptom: SPLADE is built and persisted at index time, but cannot be one of the inputs to RRF — it lives on a parallel `search_hybrid` path with different normalization (linear interp, not reciprocal-rank). The "type boost" added at `query.rs:449-454` is a *third* score-mutation site (multiplicative post-fusion) that's neither RRF-fused nor α-blended; it's just a hardcoded multiplier applied after the fact. Adding the planned reranker score as a fusion input (rather than the current "rerank top-K post-hoc" mode) would force a fourth bespoke blend path.
- **Suggested fix:** Generalize `rrf_fuse` to `fn rrf_fuse_n(ranked_lists: &[&[&str]], limit: usize) -> Vec<(String, f32)>` so any number of ranked sources contribute. Optionally introduce `trait ScoreSignal { fn rank(&self, query: &Query) -> Vec<&str>; fn weight(&self) -> f32; }` and a `FusionPipeline` that owns an ordered list of signals — semantic, FTS, SPLADE, name-fingerprint, type-boost all become uniform participants. Either fix collapses two parallel code paths into one and removes the "RRF can't take SPLADE" architectural quirk.

#### `BatchCmd::is_pipeable` is a separate exhaustive match outside the command registry
- **Difficulty:** easy
- **Location:** `src/cli/batch/commands.rs:325-364` (`BatchCmd::is_pipeable(&self) -> bool`), `src/cli/batch/commands.rs:413-538` (`fn dispatch(ctx, cmd) -> Result<Value>`), `src/cli/registry.rs:55-720` (the `for_each_command!` registry that already classifies commands)
- **Description:** Issue #1097 (PR #1114) collapsed five Group-A/B exhaustive matches in `dispatch.rs` + `definitions.rs` (`batch_support`, `variant_name`, two dispatch matches, plus the legacy `BatchSupport::Cli/Daemon` decision) into a single `for_each_command!` row per command. But the **batch-side** `BatchCmd` enum was not lifted into the same registry — it still has two exhaustive matches that must be hand-edited per command:
  - `is_pipeable()` (39-line two-arm match: pipeable vs non-pipeable)
  - `dispatch()` (130-line single-arm-per-variant match calling `handlers::dispatch_*`)
  
  And the batch enum itself lists 31 variants alongside the `Commands` enum — same surface, different file. Verified by reading `cli/batch/commands.rs:413-538` (the dispatch is a per-variant `BatchCmd::Foo { args, .. } => handlers::dispatch_foo(ctx, &args.x, &args.y, …)` chain; pure mechanical mapping, exhaustive, no compile-time link to the `Commands` enum, no link to the `for_each_command!` registry). Reduces #1114's "one row per command" promise to "one row, one batch enum variant, two batch matches".
- **Suggested fix:** Either (a) drive `BatchCmd` from `for_each_command!` so adding a row in the registry generates the `BatchCmd` variant + `is_pipeable` arm + dispatch arm + handler stub, with the row optionally carrying a `is_pipeable: true` flag and the handler ident. Or (b) merge `Commands` and `BatchCmd` into a single enum (clap derive supports this) with `is_pipeable` derived from a registry attribute. Either path eliminates the parallel batch-side fan-out.

#### `LlmProvider` resolver and `create_client` factory hardcode two providers — no registry, despite already needing one
- **Difficulty:** easy
- **Location:** `src/llm/mod.rs:200-205` (`LlmProvider` enum: 2 variants), `:284-304` (`match std::env::var("CQS_LLM_PROVIDER")` matches the strings `"anthropic"` / `"local"` literally), `:362-398` (`create_client` matches `LlmProvider::Anthropic` / `::Local` and constructs the per-provider client + writes the per-provider config-validation error message)
- **Description:** `create_client` now returns `Box<dyn BatchProvider>` (closing the bulk of EX-V1.29-3), and `LocalProvider` is a real second impl (#1101). But the **registration** of providers is still hand-coded in three places:
  1. `LlmProvider` enum in `mod.rs:200-205` (one variant per provider)
  2. `resolve()` env-var match in `mod.rs:284-304` (`Some("anthropic") | None => …, Some("local") => …, Some(other) => warn + default`) — adding a third provider requires another arm here. The env-var-name-to-variant table is implicit in this match.
  3. `create_client()` factory in `mod.rs:362-398` (one match arm per provider; each arm reads its own env vars — `ANTHROPIC_API_KEY`, etc. — and validates its own preconditions)
  
  The `API_BASE` (`https://api.anthropic.com/v1`), `API_VERSION` (`2023-06-01`), and `MODEL` (`claude-haiku-4-5`) constants in `mod.rs:167-169` are still file-level Anthropic defaults; the same constants double-duty as "is this user configured for local?" sentinels at `:381-394` (`if llm_config.api_base == API_BASE { return Err("Local requires CQS_LLM_API_BASE") }`). Add a third provider (e.g. OpenAI cloud, Gemini) and you must add its own sentinel-detection arm too.

  And `batch.rs:64-261` carries 5 hardcoded `header("anthropic-version", API_VERSION)` + `header("x-api-key", &self.api_key)` calls — the `LlmClient` impl bakes Anthropic's auth scheme. A new "OpenAI cloud" provider can't reuse `LlmClient`; it must be a fresh `BatchProvider` impl (which is fine — the trait is set up for it) but the env-var resolver and the factory both still need editing.
- **Suggested fix:** Introduce `trait ProviderRegistry { fn name(&self) -> &'static str; fn from_env(&self, cfg: &Config) -> Result<Box<dyn BatchProvider>>; }` and an inventory-style `static REGISTRY: &[&dyn ProviderRegistry]`. `resolve()`'s env-var match and `create_client`'s factory both walk the slice. Adding a provider then means: (1) impl `ProviderRegistry` for the new struct, (2) add it to the slice. The `API_BASE / MODEL / API_VERSION` constants move into the Anthropic-specific impl where they belong.

#### Vector index backend selection is a `#[cfg]`-gated hand-coded if/else chain — no `IndexBackend` trait
- **Difficulty:** medium
- **Location:** `src/cli/store.rs:423-540` (`build_vector_index_with_config<Mode>`)
- **Description:** Today the function is a 120-line `#[cfg(feature = "cuda-index")]` block with three explicit branches: (1) "chunk_count >= cagra_threshold AND gpu_available → CAGRA, try persisted, else build, persist", (2) "chunk_count < cagra_threshold → HNSW", (3) "GPU unavailable → HNSW". Each branch hand-codes the persistence path (`index.cagra` literal, `delete_persisted` cleanup, magic-number checks, fallback-on-failure). Adding a third backend requires:
  - A new `cagra_threshold`-style `CQS_FOO_THRESHOLD` env var
  - A new branch in this if/else chain
  - A new persisted-path literal + load-then-rebuild fallback
  - A new structured `tracing::info!(backend = "foo", …)` log line per code path
  - A new `gpu_available()` (or equivalent) gate
  - A new `delete_persisted` cleanup hook
  
  All of this is mechanical; nothing in `index::VectorIndex` lets a backend declare its own threshold / persistence path / availability gate. `VectorIndex` itself is a clean trait (HNSW and CAGRA both implement it), but the **selector** isn't trait-driven. Concrete pressure: the v1.30.0 release ships the `cuda-index` feature split (#956 Phase A) which already names "future Metal / ROCm" backends; each addition will edit this chain again.
- **Suggested fix:** Extend `VectorIndex` with `fn try_open(cqs_dir: &Path, store: &Store<Mode>) -> Option<Box<dyn VectorIndex>>` (where `None` means "I'm not the right backend for this config") and `fn priority(store: &Store) -> i32` (higher wins). Build a `&[&dyn IndexBackend]` slice; selection iterates highest-priority-first and returns the first non-None `try_open`. The `cagra_threshold` lives inside CAGRA's `try_open`, the `gpu_available` lives inside CAGRA's `priority`, the `index.cagra` filename lives inside CAGRA's persistence. HNSW becomes the always-priority-zero fallback. New backends are pure additions to the slice.

#### Tree-sitter query files are wired by `include_str!` per-row — no runtime / startup self-test that registry → query files are consistent
- **Difficulty:** easy
- **Location:** `src/language/queries/*.scm` (109 files), `src/language/languages.rs` (each row uses `chunk_query: include_str!("queries/<lang>.chunks.scm")`), no test that asserts coverage
- **Description:** Each `LanguageDef` row in `languages.rs` literally embeds its query strings via `include_str!("queries/<name>.<kind>.scm")`. A typo in the path is a build error — that part is fine. But:
  - There's no test that iterates `REGISTRY.all()` and asserts every code-carrying language has a non-empty `chunk_query` (an empty .scm file `include_str!`s as `""` and compiles to a no-op tree-sitter query — the language silently emits zero chunks).
  - There's no test that scans `src/language/queries/*.scm` and asserts every file is referenced by at least one language row (orphan .scm files don't break anything but are pure dead weight, and a *partially wired* language — chunks.scm but no calls.scm — won't error at compile time even though the runtime impact is "calls graph never populated for this language").
  - Adding a new query kind (e.g. a hypothetical `docs.scm` for docstring extraction) means editing every `definition_*` function to add an `include_str!` arm, with no compile-time assertion that "every language with a grammar declared a docs.scm".
  
  Verified by walking `language/queries/`: 109 files, no `tests/queries_consistent.rs` or similar self-test. The 54 supported languages × up to 3 query kinds = 162 cells, ~109 files filled — the gap (53 missing-by-design) is invisible without a registry walk.
- **Suggested fix:** Add a single `#[test] fn registry_coverage() { for lang in REGISTRY.all() { if has_grammar(lang) { assert!(!lang.chunk_query.is_empty(), "{lang:?} chunk_query empty"); } } }` in `language/mod.rs`. Catches the silent-empty-query trap with a single test. For new query kinds, layer a `phf`-style `static QUERIES: phf::Map<(&str, QueryKind), &str>` so adding a kind requires editing the map (one place) instead of every `definition_*` function.

#### `ScoringOverrides` adds a knob → must edit 4 sites (struct, defaults, env-var resolver, consumer)
- **Difficulty:** medium
- **Location:** `src/config.rs:153-172` (`ScoringOverrides` struct, 11 `Option<f32>` fields), `src/search/scoring/*.rs` (`ScoringConfig::DEFAULT` consts), `src/store/search.rs:11-42` (`RRF_K_CONFIG_OVERRIDE` + `rrf_k()` env-var-or-config resolver — one of these per knob), wherever the consumer reads it
- **Description:** Each scoring knob (`name_exact`, `parent_boost_cap`, `splade_alpha`, `rrf_k`, …) requires editing four places: (1) the struct, (2) a `pub const DEFAULT_*: f32 = …` sibling, (3) the per-knob env-var resolver function (e.g., `fn rrf_k() -> f32`), (4) the consumer that reads the resolved value. There's no shared resolver. Concrete: adding the queued "type boost factor" knob (`CQS_TYPE_BOOST` is already an env override at `query.rs:449-454`, but it doesn't appear in `ScoringOverrides` so it's environment-only — the `.cqs.toml` `[scoring]` section can't set it). New scoring knobs added by the same author have already drifted out of the `[scoring]` section schema.
- **Suggested fix:** Make `ScoringOverrides` a `HashMap<&'static str, f32>` (or a `serde(flatten)` wrapper) plus a `static SCORING_KNOBS: &[ScoringKnob]` table where each row is `(name, env_var, default, kind)`. Resolver becomes `fn resolve_knob(name: &str) -> f32` driven by the table. Adding a knob = one row. Drops the four-site fan-out to one. Bonus: enables `cqs config show` to dump every knob and its source (env / config / default) without keeping a hand-maintained list.

#### `NoteEntry` has no kind / tag taxonomy — sentiment-only, can't filter for a class
- **Difficulty:** medium
- **Location:** `src/note.rs:41-67` (`NoteEntry { sentiment: f32, ... }`, `Note { sentiment: f32, ... }`), `src/note.rs:79-89` (sentiment → "Warning: " / "Pattern: " prefix is the only kind taxonomy)
- **Description:** Notes carry a single `sentiment: f32` axis (CLAUDE.md documents 5 discrete values: `-1, -0.5, 0, 0.5, 1`). The only "kind" is implicit: sentiment < `-0.3` → "Warning:", sentiment > `+0.3` → "Pattern:", else neutral. There's no formal `kind` field. Adding a separate retrievable note class (e.g. `kind = "todo"` / `"design-decision"` / `"deprecation"` / `"known-bug"` — all of which appear in MEMORY.md and PROJECT_CONTINUITY.md as ad-hoc text patterns the user wants searchable) requires:
  - Schema migration: new `notes.kind TEXT` column
  - `NoteEntry` struct change
  - TOML serialization roundtrip update (`docs/notes.toml` parser)
  - `cqs notes add --kind …` flag
  - Search filter: `cqs notes list --kind …`
  - Sentiment-prefix logic at `note.rs:79-89` becomes kind-prefix
  
  Today the workaround is "encode the kind in the note text" (e.g. `"TODO: rebuild HNSW after migration"`), which is unsearchable as structured data. The schema-version churn for adding a column is the friction — CLAUDE.md "always do things properly" doesn't currently extend to notes shape.
- **Suggested fix:** Add a `kind: Option<String>` field on `NoteEntry` (free-string, not enum, so it's not a 50-language-style registry problem). Add a `notes.kind` SQLite column at the next schema bump. Treat `sentiment_to_prefix` as the legacy fallback for entries with `kind = None`. Filter shape: `cqs notes list --kind todo` becomes a column filter, not a regex on `text`.

#### `LanguageDef::structural_matchers` is per-language `Option<&[(name, fn)]>` — no shared library of common matchers
- **Difficulty:** easy
- **Location:** `src/language/mod.rs:191` (`type StructuralMatcherFn = fn(&str, &str) -> bool;`), `:345` (`pub structural_matchers: Option<&'static [(&'static str, StructuralMatcherFn)]>`), `src/structural.rs:93-98` (lookup site)
- **Description:** Structural matchers exist for one language so far (Rust, by reading `structural_matchers_default_none` at `language/mod.rs:2391-2395` — every other language returns `None`). The shape `Option<&'static [(&str, fn)]>` is fine for one language but doesn't scale: adding the same "swallow exceptions" or "async hot path" patterns for Python/JS/Go means rewriting the same predicate function bodies in each `definition_*` row, with no shared library. The Patterns enum at `structural.rs:44` already encodes language-agnostic ideas (`SwallowedException`, `AsyncIO`, `Mutex`, `Unsafe`) — but the matchers are per-language fn pointers, not a `(Pattern, Language) -> bool` table. The cross-language tests at `structural.rs:319-388` already cover Python/JS/Go/Rust for the *fallback regex matcher*, but the language-tuned matchers only exist for Rust.
- **Suggested fix:** Move structural matchers to a `(Pattern, Language) -> Option<&'static dyn Fn(&str, &str) -> bool>` table (or expand `LanguageDef` rows to declare a `structural_matchers: &[(Pattern, fn)]` slice referencing shared functions). Adding the same pattern across 5 languages becomes 5 table rows pointing at one function, not 5 separate fn definitions per `definition_*`.

#### `find_project_root` markers list is hardcoded — language-grammar registry has no link to "where would a project of this language root live"
- **Difficulty:** easy
- **Location:** `src/cli/config.rs:155-162` (hardcoded 6-marker list with embedded comment "EX-5: These markers are intentionally NOT derived from LanguageDef")
- **Description:** The `markers` array `["Cargo.toml", "package.json", "pyproject.toml", "setup.py", "go.mod", ".git"]` is stable — the inline comment argues the alternative ("project_root_markers on LanguageDef") would dilute the language registry. Granted. But the *current shape* is a fixed array whose authoritative semantics are "these are the 5 languages with a unambiguous canonical project file, plus `.git` as fallback." It is documented as an intentional decision. The pressure point: if cqs ever grows first-class support for Maven (`pom.xml`), Gradle (`build.gradle`/`build.gradle.kts`), .NET solutions (`*.sln`), Bazel (`MODULE.bazel`/`WORKSPACE`), Mix (`mix.exs`), or Cargo workspaces with non-`Cargo.toml` markers — the list grows here, not in `languages.rs`. The decision "where does project-root-detection live" is settled, but the 6-marker list itself is documented as sufficient for now via a comment, not as data the registry actually owns.
- **Suggested fix:** Convert to a `static PROJECT_ROOT_MARKERS: &[(&str, &str)] = &[("Cargo.toml", "rust"), …, (".git", "fallback")]` table at module level. The inline comment becomes the table's doc. Adding Maven (`pom.xml`, "java") is one row, not an `if current.join("pom.xml").exists()` arm. The "is this a workspace root?" Cargo-specific branch at `:167-172` keeps its dedicated logic but stops being in the middle of a `for marker in &markers` loop.

#### Embedder constructor coupling: `define_embedder_presets!` rows generate `pub fn <variant_fn>(&self) -> Self` constructors but no per-preset config knob extension hook
- **Difficulty:** easy
- **Location:** `src/embedder/models.rs:163-300` (`define_embedder_presets!` macro), `:313-374` (the four shipped preset rows)
- **Description:** Each preset row generates a method like `ModelConfig::bge_large(&self) -> Self` that fully populates `ModelConfig { repo, dim, max_seq_len, normalize_embeddings, query_prefix, doc_prefix, approx_download_bytes, pad_id, … }`. Adding a *cross-cutting* preset attribute (e.g. "this preset requires GPU because dim >= 1024", or "this preset's tokenizer expects BOS/EOS even though the default doesn't") means: editing `ModelConfig` (new field), editing the macro `@build_arm` to plumb the field through, editing every preset row to add the attribute. The 4 row × N field matrix is fine at 4 rows, but the preset-additive case still has a real fan-out per attribute. Verified by counting attributes per row: 9 fields per preset × 4 presets = 36 cells; one new field is 4 row-edits + 2 macro-edits.
  
  Lower priority than the others — the macro pattern actually *contains* this fan-out to one file. Flagging because the alternative (a `HashMap<&'static str, Value>` per row) would let adding a new attribute be a no-op for old presets that don't set it.
- **Suggested fix:** Optional. If new preset attributes start landing frequently, extend the macro grammar with an `extras: { gpu_only = true, expects_bos = true }` block per row that maps to a `HashMap<&'static str, ModelAttr>` field on `ModelConfig`. Keeps required fields in the row's main shape, optional/sparse ones in `extras`. Skip if presets are stable.

Summary: 9 extensibility findings. The original v1.29.0 9 findings have largely closed (registry refactor #1114, define_aux_presets, define_chunk_types, define_query_categories, ConfigSection, VisibilityRule extensions, NotesListArgs flatten, approx_download_bytes, Local LLM provider). Residual pressure now concentrates on (1) score-signal pluggability — RRF locked to two lists while SPLADE lives on a parallel α-blend path, (2) batch-side dispatch — `BatchCmd` enum + `is_pipeable` + dispatch matches did not get lifted into the registry alongside `Commands`, (3) provider/index backend selectors that still hand-code the "match on enum kind" factory shape despite having clean trait-objects on the consumer side, (4) implicit registries (project-root markers, structural matchers) that work fine today but document themselves as "intentionally not data" while bearing the structural-data shape.
