diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4829e11..1e13e38 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -33,24 +33,89 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
## crate 의존성 그래프
-```text
-kebab-cli, kebab-tui, kebab-desktop
- └─> kebab-app
- ├─> kebab-source-fs
- ├─> kebab-parse-md / kebab-parse-pdf / kebab-parse-image / kebab-parse-audio
- │ └─> kebab-parse-types
- ├─> kebab-normalize
- │ └─> kebab-parse-types
- ├─> kebab-chunk
- ├─> kebab-store-sqlite
- ├─> kebab-store-vector
- ├─> kebab-embed-local (kebab-embed trait crate)
- ├─> kebab-search
- ├─> kebab-llm-local (kebab-llm trait crate)
- ├─> kebab-rag
- ├─> kebab-eval
- └─> kebab-config
- └─> kebab-core (모두 의존)
+> 그룹 단위 view + 컴포넌트별 상세는 [docs/components/](components/).
+
+```mermaid
+flowchart TB
+ subgraph UI ["UI binary"]
+ cli["kebab-cli"]
+ tui["kebab-tui"]
+ desktop["kebab-desktop
(P9-5)"]
+ end
+ app["kebab-app
(facade)"]
+ subgraph Ingest ["ingest pipeline"]
+ srcfs["kebab-source-fs"]
+ pmd["kebab-parse-md"]
+ ppdf["kebab-parse-pdf"]
+ pimg["kebab-parse-image"]
+ paud["kebab-parse-audio
(P8 보류)"]
+ ptypes["kebab-parse-types"]
+ norm["kebab-normalize"]
+ chunk["kebab-chunk"]
+ end
+ subgraph Persist ["persistence"]
+ sqlite["kebab-store-sqlite"]
+ vector["kebab-store-vector"]
+ end
+ subgraph Adapters ["traits + adapters"]
+ embed["kebab-embed
(trait)"]
+ embedlocal["kebab-embed-local
(fastembed)"]
+ llm["kebab-llm
(trait)"]
+ llmlocal["kebab-llm-local
(Ollama)"]
+ search["kebab-search"]
+ rag["kebab-rag"]
+ end
+ eval["kebab-eval"]
+ config["kebab-config"]
+ core["kebab-core
(domain types)"]
+
+ cli --> app
+ tui --> app
+ desktop --> app
+
+ app --> srcfs
+ app --> pmd
+ app --> ppdf
+ app --> pimg
+ app --> paud
+ app --> norm
+ app --> chunk
+ app --> sqlite
+ app --> vector
+ app --> embedlocal
+ app --> llmlocal
+ app --> search
+ app --> rag
+ app --> eval
+ app --> config
+
+ pmd --> ptypes
+ ppdf --> ptypes
+ pimg --> ptypes
+ paud --> ptypes
+ norm --> ptypes
+ embedlocal --> embed
+ llmlocal --> llm
+ rag --> search
+ rag --> llm
+ rag --> sqlite
+ search --> sqlite
+ search --> vector
+ search --> embed
+ eval --> app
+
+ config --> core
+ embed --> core
+ llm --> core
+ sqlite --> core
+ vector --> core
+ chunk --> core
+ norm --> core
+ ptypes --> core
+ search --> core
+ rag --> core
+ srcfs --> core
+ eval --> core
```
UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab-app` facade 만 통한다 (frozen 설계 §8). `kebab-cli` 가 `--config ` flag 를 honor 하려면 `kebab_app::*_with_config(cfg, …)` companion 을 통해 Config 을 명시적으로 thread 하는 패턴 — 자세한 이유는 [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 의 `--config` 항목.
diff --git a/docs/components/README.md b/docs/components/README.md
new file mode 100644
index 0000000..cceb30d
--- /dev/null
+++ b/docs/components/README.md
@@ -0,0 +1,103 @@
+# Components
+
+> 책임 단위 그룹별 contributor 향 상세. 사용자 향 grand picture 는 [README.md](../../README.md), 상위 crate 의존 그래프 + 디렉토리 구조 + locked-in 결정은 [docs/ARCHITECTURE.md](../ARCHITECTURE.md), 진척도는 [HANDOFF.md](../../HANDOFF.md), per-task spec 은 [tasks/INDEX.md](../../tasks/INDEX.md).
+
+각 그룹 페이지는 동일 템플릿: 구성 crate / 구조 다이어그램 / data flow 다이어그램 / 주요 type / 외부 의존 / 핵심 결정 (HOTFIXES + spec 의 "왜") / 관련 spec / HOTFIXES.
+
+## 그룹 wiring
+
+12 그룹 간 호출/의존 흐름. 점선 = `Foundation` 이 모두에 의존. UI 는 `App facade` 만 통해 다른 그룹 도달.
+
+```mermaid
+flowchart TB
+ subgraph Surfaces ["UI surface"]
+ UI["UI
(cli + tui)"]
+ end
+ subgraph Orchestration ["orchestration"]
+ AppFacade["App facade
(kebab-app)"]
+ RAG["RAG"]
+ Eval["Eval"]
+ end
+ subgraph IngestPipe ["ingest pipeline"]
+ Source["Source"]
+ Parse["Parse"]
+ NormChunk["Normalize+Chunk"]
+ end
+ subgraph IndexQuery ["index + retrieval"]
+ Embed["Embed"]
+ Store["Store"]
+ Search["Search"]
+ end
+ subgraph Generation ["generation"]
+ LLM["LLM"]
+ end
+ Foundation["Foundation
(core + parse-types + config)"]
+
+ UI --> AppFacade
+ AppFacade --> Source --> Parse --> NormChunk
+ NormChunk --> Store
+ NormChunk --> Embed --> Store
+ AppFacade --> Embed
+ AppFacade --> Search
+ Search --> Store
+ Search --> Embed
+ AppFacade --> RAG
+ RAG --> Search
+ RAG --> LLM
+ RAG --> Store
+ AppFacade --> Eval
+ Eval --> AppFacade
+ Eval --> Store
+
+ Foundation -.-> Source
+ Foundation -.-> Parse
+ Foundation -.-> NormChunk
+ Foundation -.-> Embed
+ Foundation -.-> Store
+ Foundation -.-> Search
+ Foundation -.-> LLM
+ Foundation -.-> RAG
+ Foundation -.-> AppFacade
+ Foundation -.-> UI
+ Foundation -.-> Eval
+```
+
+## 그룹 목록
+
+| 그룹 | 역할 | 페이지 |
+|------|------|--------|
+| **Foundation** | 도메인 type + 설정 + parser IR. 모든 crate 의 zero-dep 토대. | [foundation/](foundation/) |
+| **Source** | 워크스페이스 walk + .kebabignore + BLAKE3 checksum → `RawAsset`. | [source/](source/) |
+| **Parse** | bytes → `ParsedBlock` (md) 또는 `CanonicalDocument` (pdf/image). OCR + caption 어댑터. | [parse/](parse/) |
+| **Normalize+Chunk** | `ParsedBlock` → `CanonicalDocument` lift (markdown only) + 모든 미디어 → `Vec` (md/pdf 변종 chunker). | [normalize-chunk/](normalize-chunk/) |
+| **Store** | SQLite (V001-V005, FTS5, jobs, chat sessions) + LanceDB (per-model vector 테이블) two-phase write. | [store/](store/) |
+| **Embed** | `Embedder` trait + fastembed-rs 어댑터 (multilingual-e5-small 384d). | [embed/](embed/) |
+| **Search** | lexical (FTS5 BM25) + vector (ANN) + hybrid (RRF) — `Retriever` trait 3 변종. | [search/](search/) |
+| **LLM** | `LanguageModel` trait + Ollama HTTP 어댑터 (`gemma4:e4b` default). streaming + cancel-safe. | [llm/](llm/) |
+| **RAG** | retrieve → gate → pack → generate → cite-validate → persist 9 stage pipeline. multi-turn 지원. | [rag/](rag/) |
+| **App facade** | `kebab-app` — 모든 UI binary 의 유일한 진입점. `*_with_config` companion 패턴. | [app-facade/](app-facade/) |
+| **UI** | `kebab-cli` (`--json` wire envelope) + `kebab-tui` (4 패널 + Mode machine + cheatsheet). | [ui/](ui/) |
+| **Eval** | golden query 회귀 평가 + run-vs-run compare. `must_contain` rule-based. | [eval/](eval/) |
+
+## 진입 가이드
+
+처음 읽는다면 (의존성 따라 bottom-up):
+
+1. **Foundation** — 다른 모든 페이지가 참조하는 type 정의. `AssetId` / `DocumentId` / `Chunk` / `Citation` / 5 version 등.
+2. **Source → Parse → Normalize+Chunk → Store** — ingest pipeline 흐름.
+3. **Embed → Search** — retrieval.
+4. **LLM → RAG** — generation.
+5. **App facade** — 위 전부 wiring.
+6. **UI** — facade 위.
+7. **Eval** — 독립.
+
+특정 작업 별 진입:
+
+- **새 미디어 타입 추가** (예: epub) — Parse → Normalize+Chunk → Store (chunker_version) → App facade (라우팅).
+- **새 retrieval 모드** — Search → App facade (mode dispatch) → UI (--mode flag).
+- **새 LLM 어댑터** — LLM (trait crate, 새 type 금지) + 새 `kebab-llm-` crate → App facade (config provider switch).
+- **TUI 신규 pane** — UI 만. Mode + Theme + InputBuffer 재사용.
+
+## 다이어그램 제약
+
+각 그룹 페이지의 다이어그램은 `mermaid` (Gitea / GitHub 자동 렌더). 페이지 별 최소 2개 — **구조** (type/trait/struct 관계) + **data flow** (입출력 흐름). 실제 코드와 시그니처 일치 — 작성 시 `crates/kebab-/src/lib.rs` 직접 읽음 (추측 금지).
diff --git a/docs/components/app-facade/README.md b/docs/components/app-facade/README.md
new file mode 100644
index 0000000..41eb1c8
--- /dev/null
+++ b/docs/components/app-facade/README.md
@@ -0,0 +1,182 @@
+# App facade
+
+> `kebab-app` 은 모든 UI binary (cli/tui/desktop) 가 의존하는 **유일한** 진입점. 도메인 type 만 반환 (wire envelope 은 UI 측 책임). `*_with_config` companion 패턴으로 explicit Config threading 보장.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-app` | high-level facade. 모든 pipeline crate 를 wire 해서 ingest / search / ask / list / inspect / doctor / reset / init 8개 op 노출. `App` lifecycle struct + `*_with_config` companion + `IngestEvent` streaming + cooperative cancel. |
+
+## 구조
+
+```mermaid
+classDiagram
+ class App {
+ +open_with_config(cfg) Self
+ +embedder() Option~Arc~dyn Embedder~~
+ +vector() Option~Arc~LanceVectorStore~~
+ +llm() Arc~dyn LanguageModel~
+ +search(query) Vec~SearchHit~
+ +ask_with_session(session_id, query, opts) Answer
+ -config: Config
+ -sqlite: Arc~SqliteStore~
+ -embedder: OnceLock
+ -vector: OnceLock
+ -llm: OnceLock
+ -search_cache: Option~Mutex~LruCache~~
+ }
+ class FreeFns {
+ <>
+ init_workspace(force)
+ ingest(scope, summary_only)
+ list_docs(filter)
+ inspect_doc(id) / inspect_chunk(id)
+ search(query)
+ ask(query, opts)
+ doctor()
+ }
+ class WithConfig {
+ <<#[doc(hidden)] pub fn>>
+ ingest_with_config
+ ingest_with_config_progress
+ ingest_with_config_cancellable
+ list_docs_with_config / inspect_*_with_config
+ search_with_config / search_uncached_with_config
+ ask_with_config / ask_with_session_with_config
+ doctor_with_config_path
+ }
+ class IngestEvent {
+ <>
+ Started{total}
+ AssetStarted{path}
+ AssetCompleted{counts}
+ Completed{counts}
+ Aborted{partial_counts}
+ }
+ class ResetReport {
+ scope: ResetScope
+ wiped: Vec~PathBuf~
+ }
+ class DoctorReport {
+ schema_version: u32
+ checks: Vec~DoctorCheck~
+ }
+ FreeFns ..> WithConfig : load_config + delegate
+ App ..> WithConfig : long-lived path
+ WithConfig ..> IngestEvent : stream channel
+```
+
+## Data flow — `kebab ingest` (cancellable + progress)
+
+```mermaid
+flowchart LR
+ CLI["kebab ingest --json
(또는 TUI worker)"]
+ Cfg["Config::load(--config)"]
+ App2["App::open_with_config
(SQLite, lazy embedder/vector)"]
+ Walk["FsSourceConnector.scan
(Source 그룹)"]
+ Loop["per asset 루프
(cancel poll 매 iter)"]
+ Route["MediaType 라우팅
md / pdf / image"]
+ Parse["Parse 그룹"]
+ Norm["Normalize+Chunk 그룹"]
+ Emb["Embed 그룹 (선택)"]
+ Store["Store 그룹
two-phase upsert"]
+ Bump["bump_corpus_revision
(p9-fb-19)"]
+ Event["IngestEvent::*
→ Sender~IngestEvent~"]
+ Report["IngestReport
(stage_counts, warnings)"]
+ Aborted["IngestEvent::Aborted
partial commit 보존"]
+ CLI --> Cfg --> App2 --> Walk --> Loop --> Route --> Parse --> Norm --> Emb --> Store
+ Loop -.cancel=true.-> Aborted
+ Loop -.progress.-> Event
+ Store --> Bump --> Report
+```
+
+## Data flow — `kebab ask --session ` (multi-turn)
+
+```mermaid
+flowchart LR
+ Q["query + session_id"]
+ Repo["ChatSessionRepo
get_session / list_turns"]
+ History["Vec~Turn~
(newest-first)"]
+ RAG["RagPipeline.ask_with_history"]
+ Append["append_turn
(parent updated_at bump)"]
+ Out["Answer
(conversation_id + turn_index)"]
+ Q --> Repo --> History --> RAG --> Append --> Out
+ Q -.first call.-> CreateSession["create_session
title = 첫 question 40 chars"]
+ CreateSession --> Repo
+```
+
+## 주요 type / trait / 함수
+
+**Top-level free fn** (사용자 향, XDG Config 자동 로드):
+- `init_workspace(force: bool)` — `~/.config/kebab/config.toml` + `~/.local/share/kebab/` 생성. path policy comment 자동 prepend (p9-fb-05).
+- `ingest(scope, summary_only) -> IngestReport` — workspace 전체 ingest.
+- `list_docs(filter) / inspect_doc(id) / inspect_chunk(id) / search(query) / ask(query, opts) / doctor()` — 그대로.
+
+**`*_with_config` companion** (`#[doc(hidden)] pub fn`, but **공식** API):
+- `ingest_with_config(cfg, scope, summary_only)` — 가장 단순.
+- `ingest_with_config_progress(cfg, scope, progress: Option>)` — TTY 진행 표시 / `--json` line-delimited 용.
+- `ingest_with_config_cancellable(cfg, scope, progress, cancel: Option>)` — 위 + cooperative cancel. asset loop iter 시작에서 poll → true 면 break + `Aborted{partial_counts}` + `Ok(IngestReport)` 반환 (Err 아님). 부분 commit 보존.
+- `search_with_config / search_uncached_with_config` — 후자는 LRU cache bypass (debug).
+- `ask_with_session_with_config(cfg, session_id, query, opts)` — multi-turn (p9-fb-18).
+- `doctor_with_config_path(Option<&Path>)` — config 경로 explicit.
+
+**`App` lifecycle** (long-lived caller 향 — kebab-eval, future TUI session):
+- `App::open_with_config(cfg) -> Result` — SQLite open + migration. embedder/vector/llm 은 lazy `OnceLock` 으로 첫 호출에서 build.
+- `App::embedder() -> Option<...>` — `provider == "none"` 또는 `dimensions == 0` 이면 `None` (lexical-only fallback).
+- `App::ask_with_session(session_id, query, opts)` — repo + RAG ask + 새 turn append 한 묶음 (p9-fb-18).
+- `App::search(query)` — LRU cache lookup → miss 시 `search_uncached` → put. cache key = `(query_norm, mode, k, snippet_chars, embedding_version, chunker_version, corpus_revision)`.
+
+**`IngestEvent`** (`kebab-app::ingest_progress`):
+- `Started { total }` / `AssetStarted { workspace_path }` / `AssetCompleted { counts }` / `Completed { counts }` / `Aborted { partial_counts }`. terminal frame 후 sender drop.
+
+**`ResetReport` / `ResetScope`** (`kebab-app::reset`):
+- `--all` / `--data-only` / `--vector-only` / `--config-only` (p9-fb-06).
+- TTY 가 아니면 `--yes` 필수 (silent destruction 방어).
+- `--vector-only` 가 SQLite `embedding_records` 도 truncate (off-disk Lance dir wipe 시 orphan 방지).
+
+## 외부 의존
+
+- crate dep: 거의 모든 kebab-* (source-fs, parse-md/pdf/image, normalize, chunk, store-sqlite, store-vector, embed-local, llm-local, search, rag, config, core).
+- 외부 lib: `lru` (search cache), `serde`, `anyhow`, `tracing`, `time`, `ctrlc` (CLI signal).
+- 외부 서비스: 없음 (down-stream adapter 가 가져옴).
+
+## 핵심 결정
+
+- **Facade rule: UI binary 는 `kebab-app` 만 import**.
+ **왜**: store / llm / parse / search 직접 import 가 boundary 깨짐 → UI 가 SQLite 를 알면 swap 안 됨, ONNX 를 알면 cold start 비대화. `kebab-app` 만 알게 하면 future MCP server / HTTP wrapper 도 같은 contract 위에 build.
+
+- **`*_with_config` companion = 공식 API (test seam 아님)**.
+ **왜**: top-level `ingest()` 는 `Config::load(None)` 으로 XDG default 만 읽음 → `kebab-cli --config /tmp/foo.toml` 가 silently bypass 되는 회귀 두 번 (HOTFIXES P3-5, P4-3). `kebab-cli` 는 항상 `*_with_config` 호출 — 이 패턴이 spec literal 을 후행 강화. `#[doc(hidden)]` 는 rustdoc 깨끗함만, public 그대로.
+
+- **`App.{embedder, vector, llm}` 가 `OnceLock` lazy + memoized**.
+ **왜**: `kebab list` / `kebab inspect` / `--mode lexical` 가 ONNX (~470 MB) + Lance reopen 비용 0 이어야 함. 매 CLI invocation 가 cold start 부담 = sub-second 가 다 망가짐. 첫 사용 시 build, 같은 `App` 재사용 시 재 build 안 함 (kebab-eval 50 query suite, TUI session).
+
+- **embedding 비활성 모드 (`provider = "none"` 또는 `dimensions = 0`)**.
+ **왜**: 사용자 환경 (헤드리스 / OS 미지원) 에서 embedding 끄고 lexical-only 쓰는 escape hatch. `App::embedder()` 가 `None` 반환 → search 가 mode=lexical 에 falls back. config-only switch.
+
+- **`ingest_with_config*` 3개 함수 (vanilla / progress / cancellable)**.
+ **왜**: 호출 사이트 별 capability 다름. CLI 가 progress 만 필요 / TUI worker 가 progress + cancel 둘 다. 단일 함수에 `Option` + `Option` 다 받게 하면 caller 시그니처 noisy. 3 layered: vanilla → progress wraps it (cancel=None) → cancellable wraps progress.
+
+- **`Aborted` = `Ok(IngestReport)`, `Err` 아님**.
+ **왜**: cancel 은 사용자 의도. partial commit 보존 (다음 ingest idempotent 재개). `Err` 로 처리하면 caller 가 cleanup 강요, partial 도 sweep 됨. wire 측에서 `IngestEvent::Aborted` frame 으로 cancel signal 분명, `IngestReport.partial_counts` 가 진행 상황 보유.
+
+- **`App.search_cache` LRU + `corpus_revision` snapshot**.
+ **왜**: TUI 의 매 keystroke debounced search 가 같은 query 반복. capacity 256 (~1.3 MB) cap. cache key 의 `corpus_revision` snapshot 이 ingest commit 후 자동 invalidation — 사용자가 문서 추가하면 다음 search 가 새로 쿼리 (p9-fb-19).
+
+- **wire-schema envelope = UI 측 (`kebab-cli/wire.rs`) 책임**.
+ **왜**: `kebab-app` 의 함수가 pure 도메인 type 만 반환 → kebab-tui 가 wire envelope 안 거치고 in-memory 직접 사용. `--json` 출력은 cli 가 `*.v1` envelope 으로 wrap. `DoctorReport` 만 예외 — 자체 `schema_version` 보유 (도메인 측 동등 type 없음).
+
+- **multi-turn 세션 진입 = `App` 메서드 (`ask_with_session`)**.
+ **왜**: 세션 복구 + history 빌드 + ChatSessionRepo append 가 한 transaction. caller (CLI / TUI) 가 매번 free fn 으로 호출하면 매번 `App::open` → ONNX cold start. `App` 위 메서드 = 한 번 open 후 N 번 ask.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §2.4a (ingest progress wire), §3.8 (Answer / Turn), §5.7a (chat sessions), §6.4 (defaults), §7 (facade), §8 (boundary), §10 (long-running ops), §11 (errors): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - app skeleton + ingest wiring: [`tasks/p3/p3-5-app-wiring.md`](../../../tasks/p3/p3-5-app-wiring.md), [`tasks/p6/p6-4-image-ingest-wiring.md`](../../../tasks/p6/p6-4-image-ingest-wiring.md), [`tasks/p7/p7-3-pdf-ingest-wiring.md`](../../../tasks/p7/p7-3-pdf-ingest-wiring.md)
+ - reset: [`tasks/p9/p9-fb-06-data-reset-command.md`](../../../tasks/p9/p9-fb-06-data-reset-command.md)
+ - ingest progress / cancel: [`tasks/p9/p9-fb-03-tui-ingest-background.md`](../../../tasks/p9/p9-fb-03-tui-ingest-background.md), [`tasks/p9/p9-fb-04-ingest-cancellation.md`](../../../tasks/p9/p9-fb-04-ingest-cancellation.md)
+ - search cache: [`tasks/p9/p9-fb-19-search-cache.md`](../../../tasks/p9/p9-fb-19-search-cache.md)
+ - chat session CLI: [`tasks/p9/p9-fb-18-cli-ask-session-repl.md`](../../../tasks/p9/p9-fb-18-cli-ask-session-repl.md)
+- HOTFIXES (P3-5/P4-3 `--config` 누락 + `*_with_config` 패턴, P7-3 storage UNIQUE bug, p9-fb-* 도그푸딩 후속): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/embed/README.md b/docs/components/embed/README.md
new file mode 100644
index 0000000..15975ec
--- /dev/null
+++ b/docs/components/embed/README.md
@@ -0,0 +1,119 @@
+# Embed
+
+> Chunk text → 단위 정규화된 벡터. trait + impl 패턴으로 future swap (candle / ollama-embed) 가능.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-embed` | `Embedder` trait re-export + 테스트 도구 (`assert_vector_shape`, `assert_unit_norm`) + optional `MockEmbedder` (feature gated). 새 type 추가 **금지** — 순수 facade. |
+| `kebab-embed-local` | `FastembedEmbedder` — fastembed-rs 위 ONNX-backed local 임베더. default `multilingual-e5-small` 384d. |
+
+## 구조
+
+```mermaid
+classDiagram
+ class Embedder {
+ <>
+ model_id() EmbeddingModelId
+ model_version() EmbeddingVersion
+ dimensions() usize
+ embed(inputs) Vec~Vec~f32~~
+ }
+ class EmbeddingInput {
+ text: &str
+ kind: EmbeddingKind
+ }
+ class EmbeddingKind {
+ <>
+ Document
+ Query
+ }
+ class FastembedEmbedder {
+ +new(config) Result~Self~
+ -inner: Mutex~TextEmbedding~
+ -model_id, version, dimensions, batch_size
+ }
+ class MockEmbedder {
+ feature = "mock"
+ deterministic test double
+ }
+ Embedder <|.. FastembedEmbedder
+ Embedder <|.. MockEmbedder
+ Embedder ..> EmbeddingInput
+ EmbeddingInput ..> EmbeddingKind
+```
+
+## Data flow
+
+```mermaid
+flowchart LR
+ Chunks["Vec~Chunk~
(kebab-chunk)"]
+ Inputs["EmbeddingInput
{text, kind}"]
+ Prefix["E5 prefix
Document → 'passage: '
Query → 'query: '"]
+ Batch["batch by config.batch_size"]
+ Onnx["fastembed TextEmbedding
(ONNX session, Mutex)"]
+ L2["L2 정규화
(fastembed 내장)"]
+ Vec["Vec~Vec~f32~~
unit norm, finite"]
+ Inputs --> Prefix --> Batch --> Onnx --> L2 --> Vec
+ Chunks -.text.-> Inputs
+ Query["사용자 query string
(kebab-search)"] -.text+Query.-> Inputs
+ Vec --> VStore["kebab-store-vector"]
+ Vec --> Search["kebab-search
(query 경로)"]
+```
+
+## 주요 type / trait / 함수
+
+**Trait** (`kebab-core`, re-export `kebab-embed`):
+- `Embedder::embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result>>` — 출력 shape `inputs.len()` × `dimensions()`. 결과 벡터 모두 L2 = 1 + finite.
+- `EmbeddingInput { text: &str, kind: EmbeddingKind }` — kind = `Document` / `Query` (E5 prefix 분기).
+- `EmbeddingModelId(String)`, `EmbeddingVersion(String)` — `model_id × version × dim` 으로 vector store 테이블 분리.
+
+**FastembedEmbedder** (`kebab-embed-local`):
+- `FastembedEmbedder::new(config: &kebab_config::Config) -> Result` — 모델 파일 캐시 위치 = `{model_dir}/fastembed/`. 첫 호출 시 ONNX + tokenizer 다운로드. `config.models.embedding.dimensions` 가 실제 모델 차원과 다르면 즉시 `Err` (런타임 silent mismatch 회피).
+- `Mutex` 으로 inner 세션 직렬화 — fastembed 4.9 가 `&self` 지만 보수적 lock. kebab-app 의 indexer 가 어차피 순차 batch 라 contention 없음.
+- E5 prefix 자동 적용: `Document` → `"passage: "`, `Query` → `"query: "` (§11.3).
+- L2 정규화 = fastembed 내장 (`transformer_with_precedence`). 별도 정규화 안 함, 단 `assert_unit_norm` 테스트로 invariant pin.
+
+**테스트 도구** (`kebab-embed`):
+- `assert_vector_shape(&[Vec], expected_dims)` — 길이 + finite 검증.
+- `assert_unit_norm(&[Vec], tolerance)` — L2 norm 이 `1.0 ± tolerance`. f32 384d 권장 tol = `5e-4`.
+- `MockEmbedder` (feature `mock`, default OFF) — 테스트용 deterministic double. 실 어댑터는 `kebab-embed-local` 또는 future P+ adapter 가 담당.
+
+## 외부 의존
+
+- `kebab-embed` → `kebab-core` 만 (re-export crate).
+- `kebab-embed-local` → `kebab-embed` + `kebab-config`, `fastembed`, `anyhow`.
+- 외부 lib: `fastembed-rs` (ONNX wrapper, Hugging Face 모델 다운로드 포함). 로컬 ORT runtime.
+- 외부 서비스: 첫 호출 시 모델 다운로드 (Hugging Face). 그 후 오프라인.
+
+## 핵심 결정
+
+- **`kebab-embed` = trait re-export only, **새 type 금지****.
+ **왜**: `kebab-store-vector`, `kebab-search` 등 downstream 이 `use kebab_embed::Embedder` 안정 surface 의존. `kebab-core` 재구성 시 trait 이동해도 downstream 안 깨짐. spec 가 명시 — 어댑터 코드는 `kebab-embed-local` 또는 future `kebab-embed-` 로.
+
+- **`multilingual-e5-small` 384d default**.
+ **왜**: 한국어 + 영어 동시 강함, ONNX 작음 (~120MB), 384d 가 retrieval 정확도/저장 비용 균형 좋음. e5 prefix 컨벤션 (`"passage: "` / `"query: "`) 으로 같은 모델이 doc + query 두 모드 cover.
+
+- **L2 정규화 = fastembed 내장에 위임**.
+ **왜**: fastembed 4.x 가 `transformer_with_precedence` 에서 이미 L2. 두 번 정규화 = 비용 + numerical drift. invariant 가 깨지면 `assert_unit_norm` 테스트가 즉시 실패 — fastembed 가 default 바꾸면 회귀 잡힘.
+
+- **`Mutex` 보수적 직렬화**.
+ **왜**: fastembed `&self` API 라 in principle 병렬 가능, 그러나 ORT Session 의 thread-safety 가 backend 별로 다름. indexer 가 어차피 순차 batch 라 contention 없음. profiling 에서 병목 보이면 그때 풀음.
+
+- **dim mismatch = 생성자에서 즉시 fail**.
+ **왜**: `config.models.embedding.dimensions = 384` 가 실제 모델 차원과 다르면 첫 `embed` 호출에서야 발견 → 운영 시 ingest 절반 진행 후 죽음. 생성자에서 검증 = early exit, 사용자가 즉시 config 수정.
+
+- **모델 캐시 = `{model_dir}/fastembed/` 고정 서브디렉토리**.
+ **왜**: spec literal. `model_dir` 가 `{data_dir}/models` default → 사용자가 한 곳에 모든 모델 캐시. fastembed 외 어댑터 (candle / ggml / ...) 는 자기 서브디렉토리 사용해서 충돌 회피.
+
+- **`MockEmbedder` feature gate (default OFF)**.
+ **왜**: production binary 가 mock 코드를 포함 안 함. test crate 가 `features = ["mock"]` 로 명시 opt-in.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §7.1 (helper input types `EmbeddingInput`/`EmbeddingKind`), §7.2 (`Embedder` trait), §11.3 (E5 prefix), §6.4 (`models.embedding`), §9 (versioning): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - trait crate: [`tasks/p3/p3-1-embed-trait.md`](../../../tasks/p3/p3-1-embed-trait.md)
+ - fastembed adapter: [`tasks/p3/p3-2-embed-local.md`](../../../tasks/p3/p3-2-embed-local.md)
+- HOTFIXES: 이 그룹은 머지 후 deviation 없음.
diff --git a/docs/components/eval/README.md b/docs/components/eval/README.md
new file mode 100644
index 0000000..ec6a58d
--- /dev/null
+++ b/docs/components/eval/README.md
@@ -0,0 +1,171 @@
+# Eval
+
+> Golden query 회귀 평가. fixture YAML 로드 → kebab-app facade 통해 실행 → 결과 + 메트릭을 SQLite + per_query.jsonl 로 저장. 두 run 간 비교 (compare). LLM-as-judge 안 함 — rule-based `must_contain`.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-eval` | golden fixture loader + run executor + 메트릭 computer + run-vs-run comparison + markdown 리포트. retrieval/embedding/LLM crate 직접 의존 **금지** — 모두 `kebab-app` facade 통해서만 (P5-1 inheritance). |
+
+## 구조
+
+```mermaid
+classDiagram
+ class GoldenQuery {
+ id: String
+ query: String
+ mode: SearchMode
+ k: usize
+ must_contain: Vec~String~
+ expected_doc_ids: Option~Vec~String~~
+ ask: bool
+ }
+ class QueryResult {
+ query_id
+ chunks_returned: u32
+ top_score
+ contains_all: bool
+ ask_grounded: Option~bool~
+ ask_refusal_reason
+ per_query_jsonl_path
+ }
+ class EvalRun {
+ run_id: String
+ started_at
+ finished_at
+ config_snapshot_json: String
+ results: Vec~QueryResult~
+ }
+ class AggregateMetrics {
+ recall_at_k: HashMap~k,f32~
+ precision_at_k: HashMap~k,f32~
+ no_hit_rate: f32
+ refusal_rate: f32
+ ask_grounded_rate: Option~f32~
+ }
+ class CompareReport {
+ baseline: EvalRun
+ candidate: EvalRun
+ per_query: Vec~QueryComparison~
+ kind: ComparisonKind
+ }
+ class ComparisonKind {
+ <>
+ Better
+ Same
+ Regressed
+ }
+ EvalRun --> QueryResult
+ EvalRun --> AggregateMetrics
+ CompareReport --> EvalRun
+ CompareReport --> QueryComparison
+```
+
+## Data flow — eval run
+
+```mermaid
+flowchart LR
+ YAML["fixtures/golden_queries.yaml"]
+ Load["load_golden_set"]
+ QSet["Vec~GoldenQuery~"]
+ RunId["uuid v7 simple
(timestamp-ordered)"]
+ Snapshot["5-version snapshot
(parser/chunker/embedding/
index/prompt_template)"]
+ Loop["per query 루프"]
+ Search["kebab_app::search_with_config"]
+ Ask["kebab_app::ask_with_config
(query.ask == true 시)"]
+ MustContain["must_contain
contains_all 검사"]
+ Result["QueryResult"]
+ Aggregate["compute_aggregate
recall@k / precision@k /
no_hit_rate / refusal_rate /
ask_grounded_rate"]
+ SQLiteRow["eval_runs row +
eval_query_results rows"]
+ Jsonl["runs_dir/<run_id>/
per_query.jsonl"]
+ YAML --> Load --> QSet --> Loop
+ RunId --> Snapshot --> SQLiteRow
+ Loop --> Search --> Result
+ Loop --> Ask --> Result
+ Result --> MustContain --> Aggregate
+ Aggregate --> SQLiteRow
+ Result --> Jsonl
+```
+
+## Data flow — compare two runs
+
+```mermaid
+flowchart LR
+ Base["baseline run_id"]
+ Cand["candidate run_id"]
+ LoadRows["eval_runs + eval_query_results
rows 로드"]
+ Diff["per query 비교
(contains_all flip,
top_score delta,
refusal flip)"]
+ Kind["ComparisonKind
(Better / Same / Regressed)"]
+ MD["render_report_md
(human-friendly)"]
+ Base --> LoadRows
+ Cand --> LoadRows --> Diff --> Kind --> MD
+```
+
+## 주요 type / trait / 함수
+
+**Loader** (`kebab-eval::loader`):
+- `load_golden_set(path: &Path) -> Result>` — `serde_yaml` 위 YAML 파싱.
+- `GoldenQuery { id, query, mode, k, must_contain, expected_doc_ids, ask }` — fixture 한 entry. `must_contain` 가 case-sensitive substring 검사.
+
+**Runner** (`kebab-eval::runner`):
+- `run_eval(opts: EvalRunOpts) -> Result` / `run_eval_with_config(cfg, opts) -> Result`.
+- `EvalRunOpts { golden_path, ask_enabled, k_override, ... }`.
+- 각 query → `kebab_app::search_with_config` 호출 (모든 retrieval 은 facade 통해). `ask=true` 시 추가로 `ask_with_config`.
+- `run_id` = UUID v7 simple (timestamp-ordered, lowercase hex).
+- `config_snapshot_json` = 5 version 모두 (`parser_version` / `chunker_version` / `embedding_version` / `index_version` / `prompt_template_version`) 한 번에 capture → 후일 compare 가 정확히 같은 environment 인지 검증.
+- `runs_dir//per_query.jsonl` 로 raw search hit + answer 저장 — SQLite 의 `eval_query_results` 는 메트릭 + 요약만, full payload 는 JSONL.
+
+**Metrics** (`kebab-eval::metrics`):
+- `AggregateMetrics { recall_at_k, precision_at_k, no_hit_rate, refusal_rate, ask_grounded_rate }`.
+- `TOP_K_VARIANTS` — 표준 k 값 set (1, 3, 5, 10, ...). 한 run 에서 multiple k 측정.
+- `compute_aggregate(run: &EvalRun) -> AggregateMetrics` / `_with_config` companion.
+- `store_aggregate(...)` — SQLite `eval_runs.aggregate_json` 컬럼에 저장.
+- LLM-as-judge 안 함. `ask_grounded_rate` 는 `Answer.refusal_reason.is_none() && answer.citations.len() > 0` rule.
+
+**Compare** (`kebab-eval::compare`):
+- `compare_runs(baseline_id, candidate_id) -> Result` / `_with_config`.
+- `CompareOpts { include_unchanged, k_focus }`.
+- `QueryComparison { query_id, baseline_result, candidate_result, deltas: ContainsFlipped/TopScoreDelta/RefusalFlipped }`.
+- `ComparisonKind { Better, Same, Regressed }` — overall verdict.
+- `render_report_md(&CompareReport) -> String` — markdown 본문 (PR 리뷰 첨부 용도).
+
+## 외부 의존
+
+- crate dep: `kebab-core` + `kebab-config` + `kebab-app` (facade only) + `kebab-store-sqlite` (SQLite 직접 read/write — `eval_runs` / `eval_query_results` 측). retrieval / embedding / LLM crate 직접 import **금지**.
+- 외부 lib: `serde_yaml` (golden YAML), `serde_json`, `uuid` (v7), `time`, `tracing`, `anyhow`.
+- 외부 서비스: 없음 (facade 가 가져옴).
+
+## 핵심 결정
+
+- **runner 가 `kebab-app` facade 통해서만 실행 (직접 retrieval 금지)**.
+ **왜**: facade rule (P5-1 inheritance). retrieval/embedding/LLM 의 swap 가 eval 의 contract 깨면 안 됨. 같은 facade 호출이 production 에서도 → eval 결과가 production 동작과 등가.
+
+- **rule-based `must_contain`, LLM-as-judge 거부**.
+ **왜**: LLM judge 가 stochastic + 비용 발생 + 모델 swap 시 baseline 회귀. `must_contain` substring 검사가 deterministic + 무료 + 사용자가 fixture 작성 시 이미 의도 명시. spec §11 의 비-목표 명시.
+
+- **5-version `config_snapshot_json`**.
+ **왜**: 두 eval run 비교 시 environment drift 잡음. parser_version / chunker_version 한 단계라도 다르면 비교 무의미 (다른 chunk_id, 다른 retrieval 결과). compare 가 mismatch 시 경고.
+
+- **per_query.jsonl off-disk + SQLite metrics-only**.
+ **왜**: SQLite row 가 raw search hit + answer 본문 다 보관하면 row 가 거대해짐 + FTS5 인덱스 노이즈. JSONL 은 개별 파일 → 디스크 저렴, append-only 안전, jq / fzf 로 ad-hoc 분석 가능. SQLite 는 메트릭 + 요약만.
+
+- **UUID v7 run_id**.
+ **왜**: timestamp 순 정렬 (v4 random 은 정렬 안 됨) + collision-free + lowercase hex. `runs_dir//` 가 자연 정렬 시 시간 순.
+
+- **`ask_grounded_rate` = `refusal == None && citations > 0`**.
+ **왜**: "grounded" 정의가 spec §3.8 — refusal 아니고 citation 보유. LLM 의 답변 품질 (hallucination 여부) 평가 LLM-judge 없이는 불가능, 가까운 proxy 가 grounded rate.
+
+- **compare 의 `ComparisonKind` overall verdict**.
+ **왜**: PR review 가 "regressed 인가?" 한 줄 답이 핵심. per-query delta 모두 봐야 verdict 가 나오면 leverage 안 남. `Better` / `Same` / `Regressed` 단일 verdict + 세부 delta 가 backing.
+
+- **eval 자체 cancellable 안 함**.
+ **왜**: 50-query suite 가 ~5 분. 중도 cancel 시 partial run 가 baseline 으로 쓰이면 회귀 검출 부정. CLI Ctrl-C 면 hard exit (소실 OK), partial state 저장 안 함.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §5.7 (eval_runs / eval_query_results), §6.3 (runs_dir), §11 (비-목표 = LLM-as-judge 금지), §9 (5-version cascade): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - runner: [`tasks/p5/p5-1-eval-runner.md`](../../../tasks/p5/p5-1-eval-runner.md)
+ - metrics + compare: [`tasks/p5/p5-2-eval-metrics.md`](../../../tasks/p5/p5-2-eval-metrics.md)
+- HOTFIXES (P5-1 facade-inheritance 결정, P5-2 metric definition tweaks): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/foundation/README.md b/docs/components/foundation/README.md
new file mode 100644
index 0000000..0421c6d
--- /dev/null
+++ b/docs/components/foundation/README.md
@@ -0,0 +1,151 @@
+# Foundation
+
+> 도메인 type, 설정, parser 공통 IR — workspace 의 모든 crate 가 의존하는 zero-dependency 토대.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-core` | 도메인 type + ID recipe + trait. 다른 `kebab-*` crate 에 **의존 금지** (frozen 설계 §3, §4, §7). |
+| `kebab-parse-types` | parser intermediate (`ParsedBlock`) — `kebab-core` 만 의존. parser library (`pulldown-cmark`, `lopdf` 등) 의존 금지 (§3.7b). |
+| `kebab-config` | `Config` 스키마 + XDG path resolver. `defaults → file → env (KEBAB_*)` 3 layer (§6). |
+
+## 구조
+
+```mermaid
+classDiagram
+ class IDs {
+ AssetId
+ DocumentId
+ BlockId
+ ChunkId
+ EmbeddingId
+ IndexId
+ }
+ class Versions {
+ ParserVersion
+ ChunkerVersion
+ EmbeddingVersion
+ IndexVersion
+ PromptTemplateVersion
+ }
+ class DomainTypes {
+ RawAsset
+ CanonicalDocument
+ Block enum
+ Chunk
+ Citation
+ SearchHit
+ Answer
+ Turn
+ }
+ class Traits {
+ SourceConnector
+ Extractor
+ Chunker
+ Embedder
+ Retriever
+ LanguageModel
+ DocumentStore
+ VectorStore
+ JobRepo
+ ChatSessionRepo
+ }
+ class ParsedIR {
+ ParsedBlock
+ ParsedBlockKind
+ ParsedPayload
+ Warning
+ }
+ class Config {
+ workspace
+ storage
+ indexing
+ chunking
+ models
+ search
+ rag
+ image
+ ui
+ }
+ DomainTypes ..> IDs : carries
+ DomainTypes ..> Versions : stamps
+ Traits ..> DomainTypes : produce/consume
+ ParsedIR ..> DomainTypes : Inline + SourceSpan
+ Config ..> Versions : seeds (parser/chunker/embedding)
+```
+
+## Data flow — ID recipe
+
+모든 ID 는 동일한 recipe (§4.2). tuple 의 (kind, key fields) → 정렬된 canonical JSON → `blake3` → hex 앞 32자.
+
+```mermaid
+flowchart LR
+ Tuple["tuple
(kind, key fields)"]
+ JSON["canonical JSON
(JCS, alphabetical key order)"]
+ Hash["blake3 32 byte digest"]
+ Hex["hex string 앞 32자"]
+ ID["AssetId/DocumentId/
BlockId/ChunkId/
EmbeddingId/IndexId"]
+ Tuple --> JSON --> Hash --> Hex --> ID
+```
+
+핵심 invariant: 같은 tuple → 같은 ID, 무한 idempotent. `id_for_*` helper 가 tuple 조립까지 캡슐화 — caller 는 입력만 넘김.
+
+## 주요 type / trait / 함수
+
+**IDs / 버전** (`ids.rs`, `versions.rs`):
+- `AssetId(String)` — 32 hex, `blake3` content addressed.
+- `id_for_doc(&WorkspacePath, &AssetId, &ParserVersion) -> DocumentId` — `parser_version` 갈리면 doc 도 갈림 (cascade).
+- `id_for_chunk(&DocumentId, &ChunkerVersion, &[BlockId], policy_hash: &str) -> ChunkId` — `policy_hash` 가 chunk-policy 변화 capture.
+- `id_for_embedding(&ChunkId, &EmbeddingModelId, &EmbeddingVersion, dims: usize) -> EmbeddingId`.
+
+**도메인 type** (`document.rs`, `chunk.rs`, `citation.rs`, `answer.rs`):
+- `Block` (enum) — `Heading`, `Paragraph`, `Code`, `List`, `Table`, `ImageRef`, `AudioRef`, `Quote`. `CanonicalDocument` 가 보유.
+- `Citation` — URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=…`).
+- `Answer { text, citations, refusal_reason: Option, conversation_id, turn_index, ... }` — multi-turn 메타 (p9-fb-15).
+- `Turn { question, answer, ... }` — chat history.
+
+**Trait** (`traits.rs`) — pipeline contract. 자세한 내용은 각 그룹 페이지:
+- `Extractor` (→ Parse), `Chunker` (→ Normalize+Chunk), `Embedder` (→ Embed), `Retriever` (→ Search), `LanguageModel` (→ LLM), `DocumentStore` / `VectorStore` (→ Store), `ChatSessionRepo` (→ Store, p9-fb-17).
+
+**ParsedBlock IR** (`kebab-parse-types`):
+- `ParsedBlock { kind, heading_path, source_span, payload: ParsedPayload }` — 모든 parser 의 공통 출력.
+- `Warning { kind: WarningKind, note }` — `MalformedFrontmatter` / `MalformedTable` / `EncodingFallback` / `ExtractFailed`.
+
+**Config** (`kebab-config`):
+- `Config::load(Option<&Path>) -> anyhow::Result` — 3 layer merge.
+- `Config::resolve_workspace_root(&self) -> PathBuf` — relative `workspace.root` 을 config 파일 디렉토리 기준으로 해석 (p9-fb-05).
+- `Config::xdg_config_path() / xdg_data_dir() / xdg_cache_dir() / xdg_state_dir()` — XDG 표준 디렉토리.
+- env override 키 패턴: `KEBAB__` (예: `KEBAB_RAG_SCORE_GATE`, `KEBAB_SEARCH_DEFAULT_K`). 알려지지 않은 키는 silently ignore.
+
+## 외부 의존
+
+- `kebab-core`: `serde` + `serde_json` + `serde_json_canonicalizer` (JCS) + `blake3` + `time` + `uuid`. parser/store/llm crate 의존 **금지**.
+- `kebab-parse-types`: `kebab-core` + `serde` 만.
+- `kebab-config`: `kebab-core` + `serde` + `toml` + `dirs` + `tracing`.
+
+## 핵심 결정
+
+- **ID recipe = tuple → JCS → blake3[..32]**.
+ **왜**: 동일 tuple → 항상 동일 ID. JCS (RFC 8785) 가 key 정렬을 강제해서 struct field 순서가 hash 에 영향 안 미침. 32 hex (128 bit) 가 충돌 무시할 만큼 작고 SQLite TEXT PK 로 다루기 좋음.
+ **검증**: `tests::id_for_*_pinned` 가 외부 도구 (`b3sum`) 로 hand-computed 한 hex 와 매칭 — JCS / hash pipeline 의 회귀를 즉시 잡음.
+
+- **`parser_version` / `chunker_version` / `embedding_version` / `index_version` / `prompt_template_version` 5 cascade**.
+ **왜**: 각 단계 산출물의 ID 가 상위 version 을 tuple 에 포함 → version bump 시 downstream record 가 자동으로 무효화 (frozen 설계 §9). eval runner 가 5 개 모두 `eval_runs.config_snapshot_json` 으로 snapshot.
+
+- **`Config.source_dir` (`#[serde(skip)]` + `pub(crate)`)**.
+ **왜**: `--config /tmp/cfg.toml` + `workspace.root = "kb"` 가 `cwd` 무관하게 `/tmp/kb` 로 해석되어야 함. p9-fb-05 의 path policy. `from_file` / `load` 만 stamp 하므로 외부 호출자가 망가뜨릴 수 없음.
+
+- **`#[serde(default)]` 의 점진적 신설**.
+ **왜**: pre-P6 config 파일 (`[image]` 섹션 없음) + pre-p9-fb-14 config (`[ui]` 섹션 없음) 가 그대로 load 가능. 사용자가 `kebab init` 매번 재실행 안 해도 됨.
+
+- **env override 미지의 키 silent ignore**.
+ **왜**: `KEBAB_NOPE_FOO=garbage` 같은 환경변수가 startup 을 죽이면 안 됨. whitelist 기반 명시 매칭 — grep 으로 모든 매핑 한 눈에 확인 가능.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §3 (도메인 type) / §4 (ID) / §6 (Config) / §7 (trait) / §9 (cascade): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- p9-fb-05 (`workspace.root` path policy): [`tasks/p9/p9-fb-05-config-path-policy.md`](../../../tasks/p9/p9-fb-05-config-path-policy.md)
+- p9-fb-15 (RAG multi-turn — `Turn`, `Answer.conversation_id`/`turn_index`): [`tasks/p9/p9-fb-15-rag-multi-turn-core.md`](../../../tasks/p9/p9-fb-15-rag-multi-turn-core.md)
+- p9-fb-17 (chat session storage — `ChatSessionRow`, `ChatTurnRow`, `ChatSessionRepo`): [`tasks/p9/p9-fb-17-chat-session-storage.md`](../../../tasks/p9/p9-fb-17-chat-session-storage.md)
+- HOTFIXES dated 로그 (P3-5/P4-3 `--config` 누락, P6-3 `GenerateRequest.images` 신설 등): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/llm/README.md b/docs/components/llm/README.md
new file mode 100644
index 0000000..5c4a27f
--- /dev/null
+++ b/docs/components/llm/README.md
@@ -0,0 +1,151 @@
+# LLM
+
+> 텍스트 + vision 생성 모델 인터페이스. `LanguageModel` trait + Ollama HTTP 어댑터. streaming 결과 + 항상 `Done` 종료 보장.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-llm` | `LanguageModel` trait re-export + `MockLanguageModel` (feature `mock`, default OFF). 새 type 추가 **금지** — 순수 facade. |
+| `kebab-llm-local` | `OllamaLanguageModel` — `reqwest::blocking` 기반 Ollama `POST /api/generate` 어댑터. line-delimited JSON streaming 디코드. |
+
+## 구조
+
+```mermaid
+classDiagram
+ class LanguageModel {
+ <>
+ model_ref() ModelRef
+ context_tokens() usize
+ generate_stream(req) Iterator~Result~TokenChunk~~
+ }
+ class GenerateRequest {
+ system: String
+ user: String
+ stop: Vec~String~
+ max_tokens: usize
+ temperature: f32
+ seed: Option~u64~
+ images: Vec~String~ [base64]
+ }
+ class TokenChunk {
+ <>
+ Token(String)
+ Done {finish_reason, usage}
+ }
+ class FinishReason {
+ <>
+ Stop
+ Length
+ Aborted
+ Error(String)
+ }
+ class OllamaLanguageModel {
+ +new(cfg) Result~Self~
+ -client: reqwest::blocking::Client
+ -endpoint: String
+ -model: String
+ -context_tokens: usize
+ }
+ class MockLanguageModel {
+ feature = "mock"
+ deterministic test double
+ }
+ class LlmError {
+ <>
+ ConnectionRefused
+ HttpStatus
+ Decode
+ ...
+ }
+ LanguageModel <|.. OllamaLanguageModel
+ LanguageModel <|.. MockLanguageModel
+ LanguageModel ..> GenerateRequest
+ LanguageModel ..> TokenChunk
+ TokenChunk ..> FinishReason
+ OllamaLanguageModel ..> LlmError
+```
+
+## Data flow
+
+```mermaid
+flowchart LR
+ Req["GenerateRequest
{system, user, stop,
max_tokens, temp, seed, images}"]
+ Wire["JSON wire
POST /api/generate
stream: true"]
+ Lines["line-delimited JSON
frames"]
+ Token["TokenChunk::Token(...)"]
+ DoneOk["TokenChunk::Done
{finish_reason: Stop|Length, usage}"]
+ DoneErr["TokenChunk::Done
{finish_reason: Error/Aborted, usage}"]
+ Iter["Iterator~Result~TokenChunk~~
(lazy, Send)"]
+ Req --> Wire --> Lines
+ Lines -->|frame| Token --> Iter
+ Lines -->|done frame| DoneOk --> Iter
+ Lines -->|error/abort| DoneErr --> Iter
+ Caller["caller (kebab-rag)
+ assert_finish_chunk"] --> Iter
+```
+
+## 주요 type / trait / 함수
+
+**Trait** (`kebab-core`, re-export `kebab-llm`):
+- `LanguageModel::model_ref() -> ModelRef` — provider/model/version 식별. `Answer.model_ref` 으로 흘려서 wire payload 가 자가 식별.
+- `LanguageModel::context_tokens() -> usize` — 모델 별 max prompt+completion 합. RAG 가 budget 계산에 사용.
+- `LanguageModel::generate_stream(req: GenerateRequest) -> Result> + Send>>` — async 안 됨, 매 next() 가 blocking. 모든 stream 이 마지막에 `TokenChunk::Done` 으로 끝남 (error 케이스 포함, §0 Q5).
+
+**`GenerateRequest`** (`kebab-core::traits`):
+- `images: Vec` (base64) — 빈 vec = text-only path. 비어있지 않으면 vision-capable adapter 가 `images: [...]` 로 wire 에 포함 (Ollama). 다른 backend 는 다르게 라우팅. `#[serde(default)]` — older snapshot 호환.
+
+**`TokenChunk`**:
+- `Token(String)` — partial text. 누적은 caller 책임.
+- `Done { finish_reason: FinishReason, usage: TokenUsage }` — 항상 마지막. `finish_reason::Aborted` 가 cancel signal, `Error(s)` 가 mid-stream 실패.
+
+**OllamaLanguageModel** (`kebab-llm-local::ollama`):
+- `OllamaLanguageModel::new(&kebab_config::Config) -> anyhow::Result` — `config.models.llm.endpoint` + `model` + `context_tokens` + `temperature` + `seed` 읽음. **lazy connect** — network 안 침, 첫 generate_stream 에서 실패 surface.
+- 내부: `reqwest::blocking::Client` — top-level async 표면 없음. (참고: reqwest 0.12 의 blocking 이 private current-thread tokio runtime wrap 해서 `cargo tree` 에 tokio 보임. invariant = "top-level tokio dep 없음 + async surface 노출 안 함".)
+- streaming decode: `BufReader::lines()` 위에서 `serde_json::from_str` 로 frame 별 lazy parse → `TokenChunk::Token` yield.
+
+**`LlmError`** (`kebab-llm-local::error`):
+- ConnectionRefused / HttpStatus(code) / Decode(json error) / Timeout / Aborted / 그 외 — `Err` 로 first chunk 전 surface 가능.
+
+**테스트 도구** (`kebab-llm`):
+- `assert_finish_chunk(chunks: &[TokenChunk])` — 마지막이 `Done` 이어야 — 모든 stream contract pin.
+- `MockLanguageModel` (feature `mock`, default OFF) — deterministic test double. 실 adapter 만 `Err` 가능, mock 은 항상 stream 시작 후 yield.
+
+## 외부 의존
+
+- `kebab-llm` → `kebab-core` 만 (re-export crate).
+- `kebab-llm-local` → `kebab-llm` + `kebab-config`, `reqwest` (`blocking` feature, JSON), `serde` + `serde_json`, `thiserror`, `anyhow`.
+- 외부 서비스: **Ollama HTTP** (default `http://127.0.0.1:11434`). default 모델 `gemma4:e4b` (OCR / caption / RAG 모두 같은 family — 단일 모델 다운로드면 전 시스템 동작).
+
+## 핵심 결정
+
+- **`kebab-llm` = trait re-export only, **새 type 금지****.
+ **왜**: `kebab-rag` 등 downstream 이 `use kebab_llm::LanguageModel` 안정 surface 의존. 어댑터 (Ollama/llama.cpp/candle) 는 별 crate. swap config-only.
+
+- **synchronous + blocking + stream iterator**.
+ **왜**: §0 Q5 가 streaming 명시. `async` 가 trait object 와 잘 안 맞음 (Rust async-in-trait 안정성 + Send bound 복잡). `reqwest::blocking` + line-delimited frame 의 `Iterator` 가 caller 코드 단순. RAG 가 동기 소비 + UI thread 가 별도 worker 로 spawn.
+
+- **모든 stream 이 `Done` 으로 끝남 (error 포함)**.
+ **왜**: caller 가 partial accumulation 한 텍스트 + finish reason 함께 받음. `Done(Error)` vs `Iterator::next() = None` 차이가 contract 명확. `assert_finish_chunk` 가 invariant pin.
+
+- **lazy connect (생성자에서 network 안 침)**.
+ **왜**: `kebab init` / `kebab doctor` 가 Ollama 안 떠도 동작해야 함. 첫 `generate_stream` 에서 `Err` 가 나는 게 사용자 기대 — startup 이 죽으면 진단 어려움.
+
+- **Ollama 가 default backend**.
+ **왜**: macOS / Linux 모두 single-binary install, GGUF 모델 다운로드 한 줄. local-first 의 핵심. llama.cpp / candle 어댑터는 future P+ — `LanguageModel` trait 그대로라 swap 가능.
+
+- **default 모델 `gemma4:e4b` (OCR / caption / RAG 통일)**.
+ **왜**: OCR (P6-2) + caption (P6-3) + RAG 가 같은 family 사용 → 사용자가 모델 1개만 ollama pull. variant (gemma4:26b 등) 으로 override 가능. (HOTFIXES P6-2.)
+
+- **`GenerateRequest.images: Vec` 추가 (P6-3)**.
+ **왜**: 기존 trait 가 text-only 였는데 caption 이 vision 필요. base64 image vec 으로 wire 형식 통일 — Ollama 의 `images` 필드와 1:1. text-only caller 모두 `images: Vec::new()` 마이그레이션 + `#[serde(default)]` 로 snapshot 호환. (HOTFIXES P6-3.)
+
+- **`MockLanguageModel` 가 `Err` 안 던짐**.
+ **왜**: mock 은 deterministic — first chunk 전 connection-refused 같은 케이스 시뮬레이션 안 함. 실 adapter (`OllamaLanguageModel`) 가 그 분기 책임. RAG 의 RefusalReason::LlmStreamAborted 분기 (p9-fb-15) 는 실 adapter 만 trigger.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §7.1 (`GenerateRequest`/`TokenChunk`/`FinishReason`), §7.2 (`LanguageModel` trait), §0 Q5 (streaming), §3.8 (`ModelRef`/`TokenUsage`), §6.4 (`models.llm`), §10 (errors), §11.2 (Ollama protocol notes): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - trait crate: [`tasks/p4/p4-1-llm-trait.md`](../../../tasks/p4/p4-1-llm-trait.md)
+ - Ollama adapter: [`tasks/p4/p4-2-llm-ollama.md`](../../../tasks/p4/p4-2-llm-ollama.md)
+- HOTFIXES (P6-3 `GenerateRequest.images` 추가, P6-2 OCR 기본을 Ollama vision 으로 통일): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/normalize-chunk/README.md b/docs/components/normalize-chunk/README.md
new file mode 100644
index 0000000..08e5b2f
--- /dev/null
+++ b/docs/components/normalize-chunk/README.md
@@ -0,0 +1,144 @@
+# Normalize + Chunk
+
+> Markdown 의 `ParsedBlock` 을 도메인 `CanonicalDocument` 로 lift 하고, 모든 미디어의 `CanonicalDocument` 를 검색 단위 `Chunk` 로 자른다.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-normalize` | `ParsedBlock` (markdown only) → `CanonicalDocument` lift. NFC + heading-path ordinal + provenance 합성 + title fallback chain (p9-fb-07). |
+| `kebab-chunk` | `CanonicalDocument` → `Vec`. v1 두 변종: `md-heading-v1` (markdown + image), `pdf-page-v1` (PDF). |
+
+## 구조
+
+```mermaid
+classDiagram
+ class Normalize {
+ <>
+ build_canonical_document(asset, metadata, blocks, parser_version, warnings) CanonicalDocument
+ derive_title(frontmatter, blocks, file_stem) String
+ nfc(s) String
+ }
+ class Chunker {
+ <>
+ chunker_version() ChunkerVersion
+ policy_hash(policy) String
+ chunk(doc, policy) Vec~Chunk~
+ }
+ class MdHeadingV1Chunker {
+ VERSION = "md-heading-v1"
+ BYTES_PER_TOKEN = 3
+ POLICY_HASH_HEX_LEN = 16
+ }
+ class PdfPageV1Chunker {
+ VERSION = "pdf-page-v1"
+ BYTES_PER_TOKEN = 3
+ POLICY_HASH_HEX_LEN = 16
+ }
+ class ChunkPolicy {
+ target_tokens
+ overlap_tokens
+ respect_markdown_headings
+ chunker_version
+ }
+ Chunker <|.. MdHeadingV1Chunker
+ Chunker <|.. PdfPageV1Chunker
+ MdHeadingV1Chunker ..> ChunkPolicy
+ PdfPageV1Chunker ..> ChunkPolicy
+```
+
+## Data flow
+
+```mermaid
+flowchart TD
+ Pblock["Vec~ParsedBlock~
(kebab-parse-md)"]
+ Asset["RawAsset
(kebab-source-fs)"]
+ Meta["Metadata
(frontmatter)"]
+ Pv["ParserVersion"]
+ Norm["build_canonical_document
NFC heading_path
+ ordinal per (path, kind)
+ derive_title fallback chain
+ Provenance accumulator"]
+ CDoc["CanonicalDocument
(blocks + metadata + provenance)"]
+ DirectCDoc["CanonicalDocument
(kebab-parse-pdf / kebab-parse-image)"]
+ MdC["MdHeadingV1Chunker
(markdown + image)"]
+ PdfC["PdfPageV1Chunker
(PDF)"]
+ Chunks["Vec~Chunk~"]
+ Asset --> Norm
+ Pblock --> Norm
+ Meta --> Norm
+ Pv --> Norm
+ Norm --> CDoc
+ CDoc --> MdC
+ DirectCDoc --> MdC
+ DirectCDoc --> PdfC
+ MdC --> Chunks
+ PdfC --> Chunks
+ Chunks --> Store["kebab-store-* (다음 그룹)"]
+```
+
+## 주요 type / trait / 함수
+
+**Normalize** (`kebab-normalize`):
+- `build_canonical_document(asset: &RawAsset, metadata: Metadata, blocks: Vec, parser_version: &ParserVersion, warnings: Vec) -> Result`.
+ - `doc_id = id_for_doc(workspace_path, asset_id, parser_version)`.
+ - 모든 `heading_path` 에 NFC 정규화 적용 (NFD `\u{1100}\u{1161}` 와 NFC `\u{AC00}` = "가" 가 같은 `block_id` 로 hash 되도록).
+ - ordinal = `(heading_path, block_kind)` 별 0-based, document order (§4.3).
+ - title 은 `metadata.user["title"]` lift 후, 비어 있으면 `derive_title(frontmatter_title, blocks, file_stem)` chain.
+ - `lang` 은 `metadata.user["lang"]` lift; non-string 이면 빈 `Lang`.
+ - `Provenance::events` = `Discovered` (`asset.discovered_at`) + `Parsed` + `Normalized` + 각 warning 1개 + lift-stage warning (e.g. AudioRef pre-P8 drop).
+- `derive_title(frontmatter, &[Block], file_stem) -> String` — fallback chain (p9-fb-07): frontmatter title → 첫 H1 → 첫 H2 → 첫 paragraph 80 chars → file stem → `"untitled"` sentinel.
+- `nfc(s: &str) -> String`, `to_posix(p: &Path) -> Result` — 재export from `kebab-core`.
+
+**Chunker trait** (`kebab-core`):
+- `Chunker::chunker_version() -> ChunkerVersion`.
+- `Chunker::policy_hash(&ChunkPolicy) -> String` — `blake3(canonical_json(policy))[..16]`. v1 두 chunker 가 같은 recipe.
+- `Chunker::chunk(&CanonicalDocument, &ChunkPolicy) -> Result>`.
+
+**MdHeadingV1Chunker** (`kebab-chunk`):
+- 우선순위 (§0/§14): heading 경계 → code/table 한 chunk → paragraph greedy + overlap → `heading_path` propagation.
+- `BYTES_PER_TOKEN = 3` (한국어 ≈ 3 b/tok 커버, 영어 ≈ 4 b/tok 는 over-estimate). 실제 tokenizer 도입 (P+) 까지 proxy.
+- `ImageRef` / `AudioRef` 는 자체 chunk (text = alt/caption preview, `token_estimate = 0`).
+
+**PdfPageV1Chunker** (`kebab-chunk`):
+- 모든 chunk 가 single `SourceSpan::Page { page, char_start, char_end }` — 페이지 cross 금지 (citation locality).
+- 페이지가 budget 초과 시 paragraph break (`\n\n`) → sentence end (`.`/`?`/`!` + ws) → 강제 over-size 순서로 split.
+- `chunk_id` 충돌 회피: §4.2 가 한 `block_id` 페어 → 한 `chunk_id` 가정인데 PDF 의 한 페이지 (= 한 block) 가 여러 chunk 로 split 됨. policy_hash slot 에 `format!("{base}#c{char_start}")` 변형 주입, `Chunk.policy_hash` 자체는 unmodified base 보존.
+
+## 외부 의존
+
+- crate dep:
+ - `kebab-normalize` → `kebab-core`, `kebab-parse-types` (`ParsedBlock`/`ParsedPayload`/`Warning`), `unicode-normalization`, `time`.
+ - `kebab-chunk` → `kebab-core`, `serde_json_canonicalizer`, `blake3`. parser/store/embed 의존 **금지**.
+- 외부 lib: `unicode-normalization` (NFC), `blake3` (policy_hash), `serde_json_canonicalizer` (JCS), `time` (provenance timestamps).
+- 외부 서비스: 없음.
+
+## 핵심 결정
+
+- **Markdown 만 normalize 거침; PDF / Image 는 우회**.
+ **왜**: Markdown 의 frontmatter / heading path 추적 + ordinal 부여가 normalize 의 본업. PDF 는 "페이지 = block", Image 는 "single block" 이라 IR 거치는 가치 없음. 결과: 두 path 가 chunker 단계에서 합류.
+
+- **`heading_path` NFC 정규화 (parsedBlock → canonical 시점)**.
+ **왜**: `pulldown-cmark` 가 NFC 안 함, `serde_json_canonicalizer` 도 NFC 안 함. 한국어 자모 분리/조합 두 표현이 다른 `block_id` hash 로 가면 idempotent re-ingest 가 깨짐. lift 시 NFC → on-disk `CommonBlock.heading_path` + ID input 동일 보장.
+
+- **ordinal rule = `(heading_path, block_kind)` 별 0-based, document order**.
+ **왜**: 한 heading 아래 같은 종류의 블록 (paragraph 0/1/2, code 0/1) 만 ordinal 공유. 다른 heading 으로 가면 ordinal 리셋. 같은 heading 내 paragraph 추가/삭제가 다른 종류 ordinal 안 망가뜨림.
+
+- **`derive_title` fallback chain (5단계 + sentinel)**.
+ **왜**: spec literal 의 frontmatter-only title 정책이 실제 사용자 노트 (frontmatter 없이 H1 으로 시작) 와 충돌. 5단계: frontmatter → H1 → H2 → 첫 paragraph 80자 → file stem → `"untitled"`. 각 단계 NFC, 빈 문자열 절대 반환 안 함. `parser_version` 을 `pulldown-cmark-0.x` → `md-frontmatter-v2` bump 해서 기존 doc 자동 재처리.
+
+- **`BYTES_PER_TOKEN = 3` (spec literal 의 `4` 거부)**.
+ **왜**: 한국어가 E5/M-BERT 에서 ≈ 3 bytes/token. 영어는 4 b/tok 라 3 으로 잡으면 over-estimate → 실제 tokenizer 가 봤을 때 budget 초과 안 함. 두 chunker (md/pdf) 가 같은 상수 써서 cross-chunker comparable. (HOTFIXES P7-2.)
+
+- **PDF chunk_id 충돌 회피 = policy_hash slot 에 `#c{char_start}` 변형**.
+ **왜**: 한 페이지 (= 한 block) 가 여러 chunk 로 split 되면 §4.2 의 (`doc_id`, `chunker_version`, `block_ids`, `policy_hash`) tuple 가 동일 → 같은 `chunk_id` 충돌. chunker `policy_hash` 슬롯에만 변형 주입, `Chunk.policy_hash` 필드는 base 보존 ("어떤 policy 가 active 였는지" 답변 정확). §4.2 recipe 자체는 안 바꿈. (HOTFIXES P7-2.)
+
+- **chunker 가 store/embed 의존 금지**.
+ **왜**: 순수 변환 함수. test 에서 `CanonicalDocument` 만 만들어서 chunker 호출 가능. Storage / embedding 부재가 chunker 단위 테스트 막지 않음.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §3.4 (`Block` / `CanonicalDocument`), §3.5 (`Chunk`), §3.6 (`Provenance`), §3.7b (`ParsedBlock`), §4.2 (ID recipe), §4.3 (ordinal), §0/§14 (chunking priority): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - normalize: [`tasks/p1/p1-4-normalize.md`](../../../tasks/p1/p1-4-normalize.md)
+ - chunk md: [`tasks/p1/p1-5-chunk-md.md`](../../../tasks/p1/p1-5-chunk-md.md)
+ - chunk pdf: [`tasks/p7/p7-2-chunk-pdf.md`](../../../tasks/p7/p7-2-chunk-pdf.md)
+ - title fallback: [`tasks/p9/p9-fb-07-md-title-fallback.md`](../../../tasks/p9/p9-fb-07-md-title-fallback.md)
+- HOTFIXES (P7-2 BYTES_PER_TOKEN/=3, chunk_id 충돌 회피, p9-fb-07 title chain): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/parse/README.md b/docs/components/parse/README.md
new file mode 100644
index 0000000..f772a55
--- /dev/null
+++ b/docs/components/parse/README.md
@@ -0,0 +1,154 @@
+# Parse
+
+> 미디어 타입별 추출기 — markdown / PDF / image 의 raw bytes 를 다음 단계로 흘려보낼 형태로 변환한다. 세 crate 가 같은 도메인 (`Extractor` 또는 `ParsedBlock` 출력) 에 속하지만 출력 단계가 일관되지 않다는 점이 핵심.
+
+## 구성 crate
+
+| Crate | 역할 | 출력 형태 |
+|-------|------|-----------|
+| `kebab-parse-md` | Markdown frontmatter + body parsing (P1-2/3) | `Vec` + `Metadata` (pure 함수) |
+| `kebab-parse-pdf` | text-based PDF per-page 추출 (P7-1) | `CanonicalDocument` 직접 (`Extractor` impl) |
+| `kebab-parse-image` | 이미지 메타 + EXIF + 차원 + OCR + caption (P6-1/2/3) | `CanonicalDocument` 직접 (`Extractor` impl) |
+
+## 구조
+
+```mermaid
+classDiagram
+ class Extractor {
+ <>
+ supports(MediaType) bool
+ parser_version() ParserVersion
+ extract(ctx, bytes) CanonicalDocument
+ }
+ class MdParser {
+ <>
+ parse_frontmatter(bytes) (Metadata, Span, Warnings)
+ parse_blocks(body) (Vec~ParsedBlock~, Warnings)
+ }
+ class PdfTextExtractor {
+ PARSER_VERSION = "pdf-text-v1"
+ new() Self
+ }
+ class ImageExtractor {
+ PARSER_VERSION = "image-meta-v1"
+ MAX_DECODE_DIM = 16384
+ new() Self
+ }
+ class OcrEngine {
+ <>
+ engine_id() str
+ run(image_bytes, langs) OcrText
+ }
+ class OllamaVisionOcr {
+ endpoint, model, max_pixels
+ }
+ class CaptionFns {
+ caption_image(lm, prep, opts) ModelCaption
+ apply_caption(block, lm, opts)
+ }
+ Extractor <|.. PdfTextExtractor
+ Extractor <|.. ImageExtractor
+ OcrEngine <|.. OllamaVisionOcr
+ ImageExtractor ..> OcrEngine : applied via apply_ocr
+ ImageExtractor ..> CaptionFns : applied via apply_caption
+```
+
+## Data flow
+
+세 parser 의 출력 stage 가 다른 점이 가장 중요. Markdown 만 `ParsedBlock` IR 을 거쳐 `kebab-normalize` 가 lift; PDF / Image 는 추출기 안에서 `CanonicalDocument` 까지 한 번에.
+
+```mermaid
+flowchart LR
+ Bytes["raw bytes
(RawAsset)"]
+ subgraph MD ["Markdown"]
+ MdFM["parse_frontmatter
(YAML/TOML)"]
+ MdBlocks["parse_blocks
(pulldown-cmark + line span)"]
+ Pblock["Vec~ParsedBlock~
+ Metadata"]
+ end
+ subgraph PDF ["PDF"]
+ PdfLoad["lopdf::Document::load_mem
encrypted/corrupt 거부"]
+ PdfPages["per-page extract_text
SourceSpan::Page"]
+ PdfDoc["CanonicalDocument
(page = paragraph block)"]
+ end
+ subgraph IMG ["Image"]
+ ImgDims["dims::probe
(format + WxH, ≤ 16384)"]
+ ImgExif["exif_extract
(whitelist)"]
+ ImgBlock["ImageRefBlock
(ocr=None, caption=None)"]
+ ImgOcr["OcrEngine.run
(p6-2)"]
+ ImgCap["caption_image
(p6-3, LanguageModel)"]
+ ImgDoc["CanonicalDocument
(single block)"]
+ end
+ Bytes --> MdFM --> Pblock
+ Bytes --> MdBlocks --> Pblock
+ Pblock --> Normalize["kebab-normalize
(다음 그룹)"]
+ Bytes --> PdfLoad --> PdfPages --> PdfDoc
+ Bytes --> ImgDims --> ImgBlock
+ Bytes --> ImgExif --> ImgBlock
+ ImgBlock --> ImgOcr -.optional.-> ImgDoc
+ ImgBlock --> ImgCap -.optional.-> ImgDoc
+ ImgBlock --> ImgDoc
+ PdfDoc --> Chunk["kebab-chunk
(다음 그룹)"]
+ ImgDoc --> Chunk
+ Normalize --> Chunk
+```
+
+## 주요 type / trait / 함수
+
+**Markdown** (`kebab-parse-md`):
+- `parse_frontmatter(bytes) -> (Metadata, Option, Vec)` — YAML/TOML 둘 다 인식. 파싱 실패 → `WarningKind::MalformedFrontmatter`.
+- `parse_blocks(body) -> (Vec, Vec)` — `pulldown-cmark` 위에서 heading path 추적 + 1-indexed `SourceSpan::Line`.
+- `BodyHints { title, lang }` — frontmatter 누락 시 caller 가 fallback 제공 (p9-fb-07 title fallback chain 의 entry).
+
+**PDF** (`kebab-parse-pdf`):
+- `PdfTextExtractor` — `Extractor` 구현체. `lopdf::Document::load_mem` 로 한 번 파싱, encrypted 면 즉시 bail.
+- `PARSER_VERSION = "pdf-text-v1"` — version cascade entry. (HOTFIXES P7-2 의 chunker_version `pdf-page-v1` 와 별개.)
+- 빈 페이지 / extract 실패 → `Block::Paragraph` 빈 inlines + `ProvenanceKind::Warning("scanned candidate")`. OCR fallback 미구현.
+
+**Image** (`kebab-parse-image`):
+- `ImageExtractor` — `Extractor` 구현체. `MAX_DECODE_DIM = 16384` 초과 거부 (decode bomb 방어).
+- `OcrEngine` (trait) — `engine_id() / run(...) -> OcrText`. `OcrText.engine` 필드로 trust level 분기.
+- `OllamaVisionOcr { endpoint, model, max_pixels }` — v1 유일 구현. `apply_ocr(block, engine, langs)` 가 `ImageRefBlock.ocr` 슬롯 채움.
+- `caption_image(lm: &dyn LanguageModel, prep, opts) -> Result` — `LanguageModel.generate_stream` 의 vision 입력 (`GenerateRequest.images`) 사용. `apply_caption` 이 block 에 in-place 주입.
+
+## 외부 의존
+
+- crate dep:
+ - 모든 parser → `kebab-core` (`Extractor` trait, `Block`, `Metadata`, `id_for_*`).
+ - `kebab-parse-md` → `kebab-parse-types` (`ParsedBlock`/`ParsedPayload`/`Warning`), `pulldown-cmark`, `serde_yaml`.
+ - `kebab-parse-pdf` → `lopdf`.
+ - `kebab-parse-image` → `image` (decode), `kamadak-exif` (EXIF), `kebab-core::LanguageModel` (caption).
+- 외부 서비스:
+ - PDF: 없음 (in-process).
+ - Image OCR / caption: Ollama HTTP (default `gemma4:e4b`).
+
+## 핵심 결정
+
+- **Markdown 만 `ParsedBlock` IR 사용**.
+ **왜**: §3.7b 가 "parser intermediate" 추상을 markdown 의 frontmatter / heading path 추적용으로 도입. PDF / image 는 source 자체가 단순 (PDF=페이지 평면, image=단일 블록) 이라 IR 거치지 않고 `CanonicalDocument` 바로 만드는 게 자연스러움. 결과: ingest pipeline 의 분기가 비대칭 — `kebab-app` 의 라우팅이 두 path 를 같이 처리 (HOTFIXES P7-3 가 둘의 storage 처리 통일 작업).
+
+- **PDF encrypted → hard fail (auto-decrypt 안 함)**.
+ **왜**: 자동 decryption 은 사용자의 키/뷰어 환경 가정. `kebab-parse-pdf` 는 "사용자가 외부에서 `qpdf --decrypt` 후 ingest" 명시. encrypted PDF 가 silently 빈 doc 으로 들어가는 게 더 위험.
+
+- **PDF 빈 페이지 = `Block::Paragraph` 빈 inlines + Warning provenance**.
+ **왜**: scanned PDF 식별. 빈 문자열로 chunk 만드는 비용 무시 가능 + OCR fallback (P+) 가 같은 doc 위에 in-place 추가 가능. 페이지 ordinal 보존.
+
+- **Image OCR 기본 = Ollama vision LM (Tesseract 거부)**.
+ **왜**: spec literal 의 Tesseract 가 시스템 dep (libtesseract + 언어 모델 다운로드) 를 요구해서 single-binary 약속을 깸. Ollama 가 이미 LLM 으로 깔려 있으면 추가 install 0. `OcrEngine` trait 으로 Tesseract / Apple Vision adapter 가 future swap 가능. (HOTFIXES P6-2 의 결정.)
+
+- **Caption 기본 OFF (`image.caption.enabled = false`)**.
+ **왜**: caption 은 model-generated → low trust. 매 이미지마다 모델 호출 비용 (= ingest 시간) 도 큼. opt-in. `ModelCaption.model_version` + `caption.prompt_template_version` 필드가 wire payload 로 흘러서 eval 단계에서 prompt 변화 감지 가능.
+
+- **`GenerateRequest.images: Vec` 필드 신설**.
+ **왜**: 기존 `LanguageModel` trait 가 text-only. P6-3 caption 이 vision 입력 필요해서 `images` (base64) 필드 추가. 기존 caller 모두 `images: Vec::new()` 로 마이그레이션 + `#[serde(default)]` 로 snapshot 호환. (HOTFIXES P6-3 의 결정.)
+
+- **Image decode size 캡 (`MAX_DECODE_DIM = 16384`)**.
+ **왜**: decode bomb (e.g. 100k×100k PNG) 가 메모리 즉시 OOM. 16384 = 16k px, 사진/문서 스캔 정상 케이스 충분. 초과 시 `dims::DimOutcome::Failed` + warning provenance.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §3.4 (`Block` enum), §3.7a (`OcrText` / `ModelCaption`), §3.7b (`ParsedBlock` IR), §9 (parser_version cascade), §9.1 (image policy), §9.2 (PDF text extraction): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - Markdown: [`tasks/p1/p1-2-md-frontmatter.md`](../../../tasks/p1/p1-2-md-frontmatter.md), [`tasks/p1/p1-3-md-blocks.md`](../../../tasks/p1/p1-3-md-blocks.md)
+ - PDF: [`tasks/p7/p7-1-pdf-text-extractor.md`](../../../tasks/p7/p7-1-pdf-text-extractor.md)
+ - Image: [`tasks/p6/p6-1-image-extractor.md`](../../../tasks/p6/p6-1-image-extractor.md), [`tasks/p6/p6-2-image-ocr.md`](../../../tasks/p6/p6-2-image-ocr.md), [`tasks/p6/p6-3-image-caption.md`](../../../tasks/p6/p6-3-image-caption.md)
+- HOTFIXES (P6-2 OCR 기본, P6-3 caption + `GenerateRequest.images`, P7-2 chunk_id 충돌, P7-3 storage UNIQUE bug, p9-fb-07 title fallback): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/rag/README.md b/docs/components/rag/README.md
new file mode 100644
index 0000000..c3ffc4f
--- /dev/null
+++ b/docs/components/rag/README.md
@@ -0,0 +1,148 @@
+# RAG
+
+> Retrieval-Augmented Generation pipeline. retrieve → gate → pack → generate → cite-validate → persist 단일 orchestrator. multi-turn 지원 (p9-fb-15) + cancel-safe streaming.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-rag` | `RagPipeline` + `AskOpts`. retriever / LLM / docs store 를 trait object 로 inject 받아 single-threaded 실행. concrete adapter 의존 **금지** (`kebab-llm-local` / `kebab-embed-local` / `kebab-store-vector` 직접 사용 금지 — 모두 trait 너머). |
+
+## 구조
+
+```mermaid
+classDiagram
+ class RagPipeline {
+ +new(cfg, retriever, llm, docs) Self
+ +ask(query, opts) Answer
+ +ask_with_history(query, history, conv_id, turn, opts) Answer
+ -config: Config
+ -retriever: Arc~dyn Retriever~
+ -llm: Arc~dyn LanguageModel~
+ -docs: Arc~SqliteStore~
+ }
+ class AskOpts {
+ k: usize
+ explain: bool
+ mode: SearchMode
+ temperature: Option~f32~
+ seed: Option~u64~
+ stream_sink: Option~Sender~String~~
+ history: Vec~Turn~
+ conversation_id: Option~String~
+ turn_index: Option~u32~
+ }
+ class Answer {
+ text
+ citations: Vec~AnswerCitation~
+ refusal_reason: Option~RefusalReason~
+ retrieval_summary
+ conversation_id
+ turn_index
+ model_ref
+ usage
+ trace_id
+ }
+ class RefusalReason {
+ <>
+ NoChunks
+ ScoreGate{top_score, threshold}
+ LlmStreamAborted
+ Other(String)
+ }
+ RagPipeline ..> AskOpts
+ RagPipeline ..> Answer
+ Answer ..> RefusalReason
+```
+
+## Data flow — pipeline stages
+
+```mermaid
+flowchart LR
+ Q["query + AskOpts"]
+ Expand["1. query expansion
(history 의 직전 answer
첫 200 chars concat)"]
+ Retrieve["2. Retriever.search
(k = max(opts.k, default_k))"]
+ Gate["3. score gate
top_score >= rag.score_gate?"]
+ Pack["4. pack context
chunks 를 [근거 N] 블록으로
+ Citation 보존"]
+ History["5. prepend [이전 대화]
(p9-fb-15, char budget 내
oldest drop)"]
+ Render["6. render prompt
system + [이전 대화]
+ query + [근거 N..N+m]"]
+ Gen["7. LanguageModel
.generate_stream"]
+ Stream["stream_sink 로 token 전송
(sink drop = silent swallow)"]
+ Validate["8. cite-validate
본문의 [N] 마커 →
AnswerCitation 매핑"]
+ Persist["9. answers 행 INSERT
(refusal 도 항상 persist)"]
+ Out["Answer
+ refusal_reason"]
+ Q --> Expand --> Retrieve --> Gate
+ Gate -->|empty hits| Refuse1["NoChunks refusal"] --> Persist
+ Gate -->|top < gate| Refuse2["ScoreGate refusal"] --> Persist
+ Gate -->|pass| Pack --> History --> Render --> Gen --> Stream
+ Gen -->|abort/error| Refuse3["LlmStreamAborted"] --> Persist
+ Gen -->|complete| Validate --> Persist
+ Persist --> Out
+```
+
+## 주요 type / trait / 함수
+
+**`RagPipeline`** (`kebab-rag::pipeline`):
+- `RagPipeline::new(config: Config, retriever: Arc, llm: Arc, docs: Arc) -> Self` — caller (kebab-app) 가 wire.
+- `RagPipeline::ask(&self, query: &str, opts: AskOpts) -> Result` — single-shot 또는 history 가 빈 multi-turn 첫 호출.
+- `RagPipeline::ask_with_history(&self, query, history, conversation_id, turn_index, opts) -> Result` — convenience: opts 에 3개 필드 stuff 후 `ask` 호출.
+
+**`AskOpts`** (Clone, Debug, **PartialEq 안 함** — `Sender` 가 PartialEq 구현 안 함):
+- `k: usize` — retrieval top-k. 실효는 `max(opts.k, config.search.default_k)` (config default = floor).
+- `explain: bool` — true 시 `answers.packed_chunks_json` 에 packed-context JSON 저장. refusal 은 항상 persist.
+- `mode: SearchMode` — pipeline 내부에서 mode 안 정함, caller 가 inject (lexical/vector/hybrid).
+- `temperature` / `seed: Option<...>` — config 기본값 override per call.
+- `stream_sink: Option>` — 매 `TokenChunk::Token` 동기 forward. receiver drop 시 `SendError` silent swallow + 생성 계속 (answers 행 보존).
+- `history: Vec` (p9-fb-15) — newest-first prepended `[이전 대화]` 블록. `cfg.rag.max_context_tokens * 4` 문자 budget 초과 시 oldest 부터 drop.
+- `conversation_id` / `turn_index` (p9-fb-15) — `Answer.conversation_id` / `turn_index` 로 흘러가서 wire payload 가 same-conversation 식별 가능.
+
+**`Answer`** (`kebab-core::answer`, re-export `kebab-rag`):
+- `text` + `citations: Vec` + `refusal_reason: Option` + `retrieval_summary: AnswerRetrievalSummary` + `conversation_id` + `turn_index` + `model_ref` + `usage` + `trace_id`.
+- `RefusalReason`: `NoChunks` (retrieval 비음), `ScoreGate { top_score, threshold }` (낮은 신뢰), `LlmStreamAborted` (mid-stream 중단, p9-fb-15), `Other(String)`.
+
+**상수 / 헬퍼** (`pipeline.rs`):
+- `SYSTEM_PROMPT_RAG_V1` — `prompt_template_version` 가 가리키는 system prompt. 변경 시 cascade per §9.
+- `expand_query_with_history(query, &history) -> String` — 직전 answer 첫 200 chars concat. LLM-based standalone-question rewriting 은 out of scope (P+).
+
+## 외부 의존
+
+- crate dep: `kebab-core` + `kebab-config` + `kebab-search` (`Retriever` trait 만) + `kebab-llm` (trait 만) + `kebab-store-sqlite` (`DocumentStore` + `put_answer` helper).
+- 외부 lib: `serde`/`serde_json`, `regex` (citation marker `[N]` 매칭), `time` (timestamps), `blake3` (`TraceId` 채굴), `thiserror`, `anyhow`.
+- 외부 서비스: 없음 (concrete adapter 가 가져옴).
+
+## 핵심 결정
+
+- **single-threaded synchronous orchestrator**.
+ **왜**: pipeline 의 9 stages 가 다 sequential dependency. 동시성 가치 없음. async 도입하면 caller (kebab-app, TUI worker) 가 자체 thread spawn — 단순함이 깨짐. `LanguageModel::generate_stream` 의 blocking iterator 가 자연스럽게 fit.
+
+- **`opts.k = max(opts.k, config.search.default_k)` floor**.
+ **왜**: 사용자가 `kebab ask --k 0` 같은 실수 시 retrieval starvation. config default 가 floor → "내가 더 넓히려면 높은 값 pass" 만 의미 있게 동작.
+
+- **`stream_sink` drop = silent swallow (abort 안 함)**.
+ **왜**: TUI 가 cancel 누르면 receiver drop. pipeline 이 즉시 abort 하면 `answers` 행 persist 안 됨 → debug 어려움. 끝까지 generate 후 row write, sink 만 무시 = answers 보존 + UX 자연스러움.
+
+- **refusal 도 항상 `answers` 행 INSERT**.
+ **왜**: 운영 분석 필수 — score gate 가 너무 높아서 거부 비율 분석, ScoreGate top_score 분포 등. row 부재 = 회고 불가능. row write 실패는 `tracing::warn!` 만 (caller 는 in-memory `Answer` 받음).
+
+- **모든 hit 의 chunk fetch 실패 시 `NoChunks` refusal collapse**.
+ **왜**: search → pack 사이 chunks 삭제 (다른 process 의 reset 등) 발생 시 빈 `[근거]` 블록을 LLM 에 보내면 self-refusal — 진단 misleading. 구조 원인을 알면 명시적 NoChunks 가 정확.
+
+- **history query expansion = 직전 answer 첫 200 chars concat**.
+ **왜**: full LLM-based standalone-question rewriting 이 정확하지만 한 번 더 LLM call → latency 2배 + 비결정. 200 chars concat 이 cheap deterministic, retrieval 확장 효과 충분 (대부분의 follow-up "그것" / "그게 뭐였지" 가 직전 answer 키워드 caret). spec §3.8 가 LLM-based 를 P+ 로 marking.
+
+- **`conversation_id` / `turn_index` optional**.
+ **왜**: single-shot ask 가 절반 이상의 사용. 빈 `Vec::new()` history 와 함께 `None / None` 으로 `ask` 호출 = 기존 behavior 동일. multi-turn caller (`ask_with_history`) 만 채움.
+
+- **prompt budget 초과 시 oldest history drop (newest-first 보존)**.
+ **왜**: 최근 turn 가 follow-up 컨텍스트 핵심. budget = `cfg.rag.max_context_tokens * 4` (chars-per-token proxy). spec §3.8.
+
+- **forbidden deps: `kebab-llm-local` / `kebab-embed-local` / `kebab-store-vector` 직접 사용 금지**.
+ **왜**: pipeline 가 trait 만 의존하면 test 가 mock 으로 swap 가능. 어댑터 직접 import = 테스트가 ONNX 모델 다운로드 / Ollama 서버 / LanceDB 디렉토리 필요. 단위 테스트 격리 보장.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §0 Q4 (RAG 9 stages), §1 (cite-validate), §2.3 (`[근거]` 블록 형식), §3.8 (Answer / Turn / RefusalReason), §6.4 (`rag.score_gate` / `max_context_tokens` / `prompt_template_version`), §7.2 (`Retriever` / `LanguageModel` traits), §9 (cascade): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - pipeline: [`tasks/p4/p4-3-rag-pipeline.md`](../../../tasks/p4/p4-3-rag-pipeline.md)
+ - multi-turn core: [`tasks/p9/p9-fb-15-rag-multi-turn-core.md`](../../../tasks/p9/p9-fb-15-rag-multi-turn-core.md)
+- HOTFIXES (P4-3 `--config` 누락, p9-fb-15 multi-turn 위 `Answer`/`Turn` 필드 추가, p9-fb-20 citation 표면): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/search/README.md b/docs/components/search/README.md
new file mode 100644
index 0000000..42d9c5c
--- /dev/null
+++ b/docs/components/search/README.md
@@ -0,0 +1,137 @@
+# Search
+
+> 검색 백엔드 — lexical (FTS5 BM25) + vector (LanceDB ANN) + hybrid (RRF fusion). 같은 `Retriever` trait, `SearchMode` 로 dispatch.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-search` | `LexicalRetriever` (P2-2) + `VectorRetriever` (P3-4) + `HybridRetriever` (P3-4). 모두 `kebab-core::Retriever` 구현. citation hydration 헬퍼 포함. |
+
+## 구조
+
+```mermaid
+classDiagram
+ class Retriever {
+ <>
+ search(query) Vec~SearchHit~
+ index_version() IndexVersion
+ }
+ class LexicalRetriever {
+ +new(store, index_version) Self
+ +with_settings(store, snippet_chars, ...)
+ -store: Arc~SqliteStore~
+ }
+ class VectorRetriever {
+ +new(store, vector_store, embedder, ...) Self
+ -store: Arc~SqliteStore~
+ -vector_store: Arc~dyn VectorStore~
+ -embedder: Arc~dyn Embedder~
+ }
+ class HybridRetriever {
+ +new(cfg, lexical, vector) Self
+ +with_policy(lex, vec, FusionPolicy, k)
+ -lexical: Arc~dyn Retriever~
+ -vector: Arc~dyn Retriever~
+ -fusion: FusionPolicy
+ -default_k: usize
+ }
+ class FusionPolicy {
+ <>
+ Rrf{k_rrf}
+ }
+ class SearchMode {
+ <>
+ Lexical
+ Vector
+ Hybrid
+ }
+ Retriever <|.. LexicalRetriever
+ Retriever <|.. VectorRetriever
+ Retriever <|.. HybridRetriever
+ HybridRetriever --> LexicalRetriever
+ HybridRetriever --> VectorRetriever
+ HybridRetriever ..> FusionPolicy
+ HybridRetriever ..> SearchMode : dispatch
+```
+
+## Data flow
+
+```mermaid
+flowchart LR
+ Q["SearchQuery
{text, mode, k, filters}"]
+ Disp["mode dispatch"]
+ Lex["LexicalRetriever
SQLite FTS5 + bm25
+ snippet/highlight"]
+ Vec["VectorRetriever
Embedder.embed(query)
→ VectorStore.search
→ SQLite hydrate"]
+ Fan["k × HYBRID_FANOUT_MULTIPLIER (2)
각 측 fanout"]
+ RRF["RRF fusion
score = Σ 1 / (k_rrf + rank_m)
fusion_score / (2 / (k_rrf+1))
→ [0,1]"]
+ Merge["chunk 양측 등장 시
lexical snippet 우선
(FTS5 highlight 가 사용자 친화)"]
+ Hits["Vec~SearchHit~
(snippet, citation,
heading_path, retrieval)"]
+ Q --> Disp
+ Disp -->|Lexical| Lex --> Hits
+ Disp -->|Vector| Vec --> Hits
+ Disp -->|Hybrid| Fan --> Lex
+ Disp -->|Hybrid| Fan --> Vec
+ Lex -.-> RRF
+ Vec -.-> RRF
+ RRF --> Merge --> Hits
+```
+
+## 주요 type / trait / 함수
+
+**Trait** (`kebab-core`):
+- `Retriever::search(&SearchQuery) -> Result>`.
+- `Retriever::index_version() -> IndexVersion` — hybrid 가 두 측 version 다르면 stale-index 경고.
+
+**LexicalRetriever** (`kebab-search::lexical`):
+- `LexicalRetriever::new(store: Arc, index_version: IndexVersion) -> Self`.
+- `LexicalRetriever::with_settings(store, snippet_chars, ...)` — `snippet`, `highlight` SQL 함수 호출. FTS5 `MATCH` 쿼리.
+- 구현: `SELECT chunk_id, bm25(...), snippet(...), highlight(...) FROM chunks_fts WHERE chunks_fts MATCH ? ...`. citation_helper 가 `Citation::Line { L_start, L_end }` / `Page { p }` 등 분기.
+
+**VectorRetriever** (`kebab-search::vector`):
+- `VectorRetriever::new(store: Arc, vector_store: Arc, embedder: Arc, ...)`.
+- 구현: query text → `embedder.embed(EmbeddingKind::Query, ...)` → `vector_store.search(vec, k, filters)` → SQLite 로 hydrate (snippet, citation 등은 vector hit 의 `chunk_id` 로 SELECT).
+- `Arc` runtime injection — concrete adapter (`kebab-embed-local::FastembedEmbedder`) 는 caller 가 wire.
+
+**HybridRetriever** (`kebab-search::hybrid`):
+- `HybridRetriever::new(&Config, Arc lex, Arc vec) -> Self` — `config.search.hybrid_fusion` (`"rrf"`) + `config.search.rrf_k` 읽음. 두 retriever 의 `index_version` 가 다르면 `tracing::warn`.
+- `FusionPolicy::Rrf { k_rrf }` — default 60. `with_policy` 헬퍼로 explicit 지정 가능.
+- 상수: `DEFAULT_K = 10` (query.k == 0 fallback), `DEFAULT_K_RRF = 60`, `HYBRID_FANOUT_MULTIPLIER = 2`.
+- merge rule: 양측 등장 chunk 의 `snippet` / `citation` / `heading_path` 는 lexical 측에서 가져옴 (FTS5 highlight 가 vector 의 truncated text 보다 user-relevant).
+
+## 외부 의존
+
+- crate dep: `kebab-core` + `kebab-config` + `kebab-store-sqlite` + `kebab-store-vector` + `kebab-embed` (trait re-export). `kebab-embed-local` 은 caller 가 inject (forbidden direct dep).
+- 외부 lib: `rusqlite` (FTS5 쿼리), `globset` (filter 매칭), `serde_json`, `tracing`.
+- 외부 서비스: 없음.
+
+## 핵심 결정
+
+- **세 retriever 모두 같은 `Retriever` trait**.
+ **왜**: `HybridRetriever` 가 `Arc` 두 개 받으니 lexical/vector 가 자기들도 trait object 가능. test 가 `CannedRetriever` mock 으로 두 측 inject 가능 — RRF 만 검증할 때 SQLite/Lance 부재해도 됨.
+
+- **`HybridRetriever` 가 `Arc` 직접 안 받음**.
+ **왜**: vector retriever 가 이미 embedder 보유. hybrid 는 `mode == Hybrid` 시 양측 fanout, 직접 embedding 안 함 → vector 측이 자기 embedder 사용. concrete adapter (`kebab-embed-local`) 가 hybrid 에 노출 안 됨 → forbidden dep 깨끗.
+
+- **RRF fanout = `k * 2`**.
+ **왜**: spec literal 의 floor. lexical 과 vector 의 disjoint set (한쪽만 surface 한 chunk) 이 충분히 넓어야 fused top-k 가 의미 있음. 비용 linear, recall 회복 큼.
+
+- **양측 등장 chunk = lexical snippet 우선**.
+ **왜**: vector 의 raw chunk text 는 보통 truncated (snippet_chars 짧음, BM25 highlight 없음). FTS5 의 `snippet()` + `highlight()` 가 user-perceived relevance 강함. citation/heading_path 도 lexical 결과가 정확 (vector 측은 SQLite hydrate 값 같지만 lexical 일관성).
+
+- **`fusion_score` `[0, 1]` 정규화 (post-merge hotfix)**.
+ **왜**: raw RRF score 는 `Σ 1/(k_rrf + rank)` 라 max 가 `2/(k_rrf+1)` (양측 모두 rank=1). mode 간 (Lexical 0~1 BM25 normalized, Vector cosine 0~1, Hybrid 0~?) 비교 가능하려면 `[0,1]` 정규화 필요. raw 를 `2/(k_rrf+1)` 로 나눔. (HOTFIXES "RRF fusion_score `[0,1]` 정규화" 항목.)
+
+- **두 측 `index_version` mismatch = warn (not error)**.
+ **왜**: lexical 이 v2, vector 가 v1 (re-embed 안 했음) 같은 stale state 가 운영 시 일어남. 즉시 fail = ingest 끝나기 전 search 막힘. warning 만 띄우고 계속 동작 = 사용자가 인지하고 re-index 결정.
+
+- **`kebab-embed` (trait crate) 만 의존, `kebab-embed-local` (concrete) **금지****.
+ **왜**: future MVP 의 swap 가능성 (candle, ollama-embed 등). `kebab-search` 가 concrete 어댑터 import 하면 `kebab-embed-local` 의 fastembed dep (큰 ONNX runtime) 이 search 에 강제 → unrelated build 비용. caller 가 runtime inject.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §3.7 (SearchHit), §6.4 (`search.hybrid_fusion`/`rrf_k`/`default_k`/`snippet_chars`), §0 Q3 (citation), §1.6 (`--explain`), §7.2 (`Retriever` trait): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - lexical: [`tasks/p2/p2-2-search-lexical.md`](../../../tasks/p2/p2-2-search-lexical.md)
+ - vector + hybrid: [`tasks/p3/p3-4-hybrid-fusion.md`](../../../tasks/p3/p3-4-hybrid-fusion.md)
+- HOTFIXES (RRF `fusion_score [0,1]` 정규화): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/source/README.md b/docs/components/source/README.md
new file mode 100644
index 0000000..e7508bc
--- /dev/null
+++ b/docs/components/source/README.md
@@ -0,0 +1,107 @@
+# Source
+
+> 워크스페이스를 walk 하고 gitignore-style 필터로 거른 뒤 BLAKE3 checksum 으로 컨텐츠 어드레스된 `RawAsset` 목록을 만든다. ingest pipeline 의 첫 단계.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-source-fs` | 로컬 파일시스템 `SourceConnector` 단일 구현. `kebab-config` + `kebab-core` 만 의존. |
+
+## 구조
+
+```mermaid
+classDiagram
+ class FsSourceConnector {
+ +new(config) Result~Self~
+ +scan(scope) Result~Vec~RawAsset~~
+ -default_root: PathBuf
+ -default_exclude: Vec~String~
+ -copy_threshold_bytes: u64
+ }
+ class WalkerModule {
+ build_overrides(root, excludes, kbignore) Override
+ read_kbignore(root) Vec~String~
+ walk_files(root, overrides) Vec~PathBuf~
+ }
+ class HashModule {
+ hash_file(path) (byte_len, hex)
+ }
+ class MediaModule {
+ media_type_for(path) MediaType
+ }
+ class SourceConnector {
+ <>
+ scan(scope) Vec~RawAsset~
+ }
+ SourceConnector <|.. FsSourceConnector
+ FsSourceConnector --> WalkerModule
+ FsSourceConnector --> HashModule
+ FsSourceConnector --> MediaModule
+```
+
+## Data flow
+
+```mermaid
+flowchart LR
+ Cfg["Config
(workspace.root, exclude, copy_threshold_mb)"]
+ Scope["SourceScope
(per-call lens)"]
+ Walk["walk_files
walkdir + gitignore overrides
+ symlink cycle guard"]
+ KBI[".kebabignore
(매 scan 재읽음)"]
+ Default["DEFAULT_EXCLUDES
.DS_Store / ._*"]
+ Hash["blake3 file hash
(byte_len + hex)"]
+ Media["media_type_for
(extension → MediaType)"]
+ Posix["to_posix
NFC + leading ./ strip
# 거부"]
+ Asset["RawAsset
{asset_id, workspace_path,
media_type, byte_len,
checksum, stored}"]
+ Sort["sort by workspace_path"]
+ Cfg --> Walk
+ Scope --> Walk
+ KBI --> Walk
+ Default --> Walk
+ Walk --> Hash --> Asset
+ Walk --> Media --> Asset
+ Walk --> Posix --> Asset
+ Asset --> Sort --> Out["Vec~RawAsset~"]
+```
+
+## 주요 type / trait / 함수
+
+- `FsSourceConnector::new(&Config) -> Result` — `Config::resolve_workspace_root()` 호출 (p9-fb-05 path policy 통일), `copy_threshold_mb * 1 MiB` 미리 곱해 둠.
+- `FsSourceConnector::scan(&SourceScope) -> Result>` — kebab-core `SourceConnector` trait 구현. 결정성 보장 sort by `workspace_path`.
+- `walker::build_overrides(root, config_exclude, kbignore_patterns) -> Override` — `ignore` crate 의 gitignore engine 위에서 union 빌드. 모든 패턴이 `!` prefix (positive override = include 의미라 negate 필요).
+- `walker::read_kbignore(root) -> Vec` — `/.kebabignore` 매 scan 재읽음 (long-running process 가 file edit 즉시 반영).
+- `walker::walk_files(root, overrides) -> Vec` — `walkdir::WalkDir::follow_links(true)` + 별도 `visited: HashSet` 으로 symlink cycle 방어.
+- `hash::hash_file(path) -> Result<(byte_len, hex)>` — streaming BLAKE3, 큰 파일도 메모리 안 쌓음.
+- `media::media_type_for(path) -> MediaType` — extension 기반 단순 매핑 (`.md` → `Markdown`, `.pdf` → `Pdf`, `.png/.jpg/...` → `Image(_)` 등).
+
+## 외부 의존
+
+- crate dep: `kebab-core` (`RawAsset`, `Checksum`, `SourceConnector`, `id_for_asset`, `to_posix`), `kebab-config` (`Config`).
+- 외부 lib: `walkdir` (디렉토리 walk + symlink follow), `ignore` (gitignore override engine, walker 는 안 씀), `blake3` (file hash), `time` (`OffsetDateTime::now_utc` for `discovered_at`).
+- 외부 서비스: 없음.
+
+## 핵심 결정
+
+- **`walkdir` 사용 (not `ignore::WalkBuilder`)**.
+ **왜**: `ignore::WalkBuilder` 가 gitignore + cycle detection 을 한 번에 묶지만, sibling-subtree symlink (`a -> ../b`) 의 cycle 을 ancestor-only check 가 놓치는 케이스가 있음. `walkdir` + 자체 `visited` set 으로 canonical-path 비교를 명시 제어.
+
+- **DEFAULT_EXCLUDES = `.DS_Store` + `._*`**.
+ **왜**: macOS Finder 메타파일 + AppleDouble resource fork. 모든 사용자 `.kebabignore` 에 들어갈 noise — 한 번에 baked-in. 사용자가 끄려면 별 메커니즘 필요 (현재 미제공, 필요 시 P+).
+
+- **`AssetStorage::{Copied, Reference}` = intent signal, not actual copy**.
+ **왜**: scan 단계는 byte_len 만 보고 의도만 표시. 실제 디스크 copy 는 P1-6 의 asset writer 책임. `copy_threshold_mb = 100` (default) 미만은 `Copied`, 이상은 `Reference + sha`. 큰 파일 중복 저장 회피.
+
+- **`SourceScope::include` 무시 (router 책임)**.
+ **왜**: §6.2 의 `WorkspaceCfg.include` 는 extractor router 가 적용. SourceConnector 가 또 필터링하면 markdown/PDF 가 router 에 도달 전 이중 필터. Connector 는 모든 non-excluded 파일 emit.
+
+- **Filename `#` 거부 = warn + skip (not abort)**.
+ **왜**: `to_posix` 가 `#` 포함 path 를 Err 반환 (Citation URI fragment separator 와 충돌). 단일 파일이 10000 파일 ingest 를 죽이면 안 됨 — `tracing::warn` 로 알리고 다음 파일.
+
+- **scan 결과 sort by `workspace_path`**.
+ **왜**: 결정성. 두 번 scan 의 `Vec` 가 `discovered_at` 빼고 동일해야 idempotent ingest 가능. test `scan_idempotent_modulo_timestamp` 가 회귀 핀.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §3.3 (`RawAsset`), §6.2 (workspace + .kebabignore), §6.6 (POSIX 정규화), §7.1 (`SourceScope`), §7.2 (`SourceConnector`): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec: [`tasks/p1/p1-1-source-fs.md`](../../../tasks/p1/p1-1-source-fs.md)
+- p9-fb-05 (`expand_tilde` shim 제거 + `Config::resolve_workspace_root` 일원화): [`tasks/p9/p9-fb-05-config-path-policy.md`](../../../tasks/p9/p9-fb-05-config-path-policy.md)
diff --git a/docs/components/store/README.md b/docs/components/store/README.md
new file mode 100644
index 0000000..e477f48
--- /dev/null
+++ b/docs/components/store/README.md
@@ -0,0 +1,152 @@
+# Store
+
+> persistence layer 두 백엔드 — SQLite (메타 + lexical FTS + jobs + chat) + LanceDB (per-model vector 테이블). 둘이 협조해 partial-write Lance 행이 caller 에 노출되지 않게 two-phase 보장.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-store-sqlite` | `DocumentStore` + `JobRepo` + `ChatSessionRepo` + asset writer + FTS5 lexical 쿼리 + eval 저장. refinery migration runner. |
+| `kebab-store-vector` | `VectorStore` LanceDB 어댑터. per-model `chunk_embeddings__.lance/` 테이블. SQLite 와 two-phase write 협조. |
+
+## 구조
+
+```mermaid
+classDiagram
+ class DocumentStore {
+ <>
+ put_asset(a)
+ put_document(d)
+ put_blocks(doc, blocks)
+ put_chunks(doc, chunks)
+ get_document(id)
+ list_documents(filter)
+ }
+ class VectorStore {
+ <>
+ ensure_table(model, dim) IndexId
+ upsert(recs)
+ search(query_vec, k, filters) Vec~VectorHit~
+ delete_by_chunk_ids(ids)
+ }
+ class JobRepo {
+ <>
+ create / update_progress / finish / list
+ }
+ class ChatSessionRepo {
+ <>
+ create_session / get_session / list_sessions
+ delete_session / append_turn / list_turns
+ }
+ class SqliteStore {
+ +open(cfg) Self
+ +run_migrations()
+ +put_asset_with_bytes(asset, bytes)
+ +corpus_revision() u64
+ +bump_corpus_revision()
+ +stale_chunk_ids_at(workspace_path)
+ +rebuild_chunks_fts()
+ }
+ class LanceVectorStore {
+ +new(cfg, Arc~SqliteStore~) Self
+ -tokio::Runtime current-thread
+ }
+ DocumentStore <|.. SqliteStore
+ JobRepo <|.. SqliteStore
+ ChatSessionRepo <|.. SqliteStore
+ VectorStore <|.. LanceVectorStore
+ LanceVectorStore --> SqliteStore : two-phase coord
+```
+
+## Data flow — two-phase upsert (write 경로)
+
+```mermaid
+flowchart LR
+ Caller["kebab-app"]
+ PendingIns["1. SQLite INSERT
embedding_records
status='pending'"]
+ LanceWrite["2. Lance MergeInsert
(keyed on chunk_id)"]
+ CommitFlip["3. SQLite UPDATE
status='committed'"]
+ SearchJoin["search() join
WHERE status='committed'
(pending 행 안 보임)"]
+ Caller --> PendingIns --> LanceWrite --> CommitFlip
+ SearchJoin -.- CommitFlip
+ Crash["프로세스 crash
(phase 2 또는 phase 3 사이)"]
+ Retry["다음 upsert 가
idempotent 재시도
(MergeInsert 가 chunk_id 로 dedupe)"]
+ LanceWrite -.crash.-> Crash --> Retry
+```
+
+## Data flow — re-ingest orphan cleanup (delete 경로)
+
+```mermaid
+flowchart LR
+ Bytes["asset bytes 변경
(같은 workspace_path)"]
+ Purge["purge_orphan_at_workspace_path
(SQLite 의 stale chunk_id 수집)"]
+ LanceDel["VectorStore::delete_by_chunk_ids
(LanceDB 행 삭제)"]
+ Reingest["새 doc_id / chunk_id 로 정상 ingest"]
+ Bytes --> Purge --> LanceDel --> Reingest
+```
+
+## 주요 type / trait / 함수
+
+**`SqliteStore`** (`kebab-store-sqlite::store`):
+- `SqliteStore::open(&kebab_config::Config) -> Result` — `data_dir/sqlite` 파일 열고 `Mutex` 으로 wrap.
+- `SqliteStore::run_migrations()` — `refinery` 가 `migrations/V001..V005` 적용.
+- `put_asset_with_bytes(asset, bytes)` — content-addressable 저장 (`{asset_dir}///` blob), `assets.workspace_path` UNIQUE 충돌 시 `purge_orphan_at_workspace_path` 후 재삽입 (HOTFIXES P7-3).
+- `corpus_revision() -> u64` / `bump_corpus_revision() -> Result` — `kv` 테이블의 monotonic counter (p9-fb-19, search cache invalidation key).
+- `stale_chunk_ids_at(&WorkspacePath) -> Vec` — re-ingest 시 LanceDB 에서 지울 행 식별.
+- `rebuild_chunks_fts()` — FTS5 virtual table 풀 리빌드 (corruption 또는 스키마 변경 시).
+
+**SQLite migrations** (`migrations/`):
+- **V001** — full P1 schema: `assets`, `documents`, `document_tags`, `blocks`, `chunks`, `embedding_records`, `jobs`, `ingest_runs`, `answers`, `eval_runs`, `eval_query_results`.
+- **V002** — `chunks_fts` (FTS5 virtual, `unicode61 remove_diacritics 2`) + `chunks_ai`/`ad`/`au` sync triggers. 본문은 frozen 설계 §5.5 verbatim.
+- **V003** — `embedding_records.status` lifecycle marker (`pending`/`committed`) + `idx_embed_status`. P3-3 two-phase write 의 SQLite 측.
+- **V004** — `kv (key TEXT PK, value TEXT)` + `corpus_revision = '0'` seed (p9-fb-19).
+- **V005** — `chat_sessions` + `chat_turns` (FK ON DELETE CASCADE) + `idx_chat_turns_session` (p9-fb-17). spec PR 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트.
+
+**`LanceVectorStore`** (`kebab-store-vector::store`):
+- `LanceVectorStore::new(&Config, Arc) -> Result` — `Arc` 공유로 two-phase coord.
+- `VectorStore` trait 구현. 모든 메서드 안에서 private current-thread `tokio::runtime::Runtime` 위 `block_on` (sync trait → async LanceDB 브리지).
+- 테이블 명: `chunk_embeddings__.lance/` (예: `chunk_embeddings_multilingual-e5-small_384.lance/`). 모델/차원 변경 시 자동 새 테이블 = embeddings 분리.
+- `delete_by_chunk_ids(&[ChunkId])` — default impl 빈 no-op (P7-3 follow-up 으로 LanceVectorStore 만 override).
+
+## 외부 의존
+
+- `kebab-store-sqlite` → `kebab-core` + `kebab-config`, `rusqlite` (`bundled` feature, libsqlite3 시스템 dep 회피), `refinery`, `serde_json`, `time`, `blake3`, `globset`.
+- `kebab-store-vector` → `kebab-core` + `kebab-config` + `kebab-store-sqlite`, `lancedb`, `arrow` (Lance schema), `tokio` (current-thread runtime).
+- 외부 서비스: 없음. 모든 저장 on-disk.
+
+## 핵심 결정
+
+- **`rusqlite` `bundled` feature**.
+ **왜**: 시스템 `libsqlite3` 의존을 single-binary 약속이 거부. `bundled` 가 SQLite source 를 같이 빌드 → `kebab` 깔면 추가 install 0.
+
+- **per-model Lance 테이블**.
+ **왜**: 임베딩 모델 swap 시 이전 모델의 vector 가 그대로 남아 있어야 (re-embed 유예) + 같은 chunk 가 두 모델로 동시에 indexed 가능. 테이블 명에 `_` 인코딩 → `EmbeddingModelId` 변경 = 새 테이블, 기존은 read-only 유지.
+
+- **two-phase write (SQLite first, Lance second, status flip)**.
+ **왜**: LanceDB 는 transactional commit 없음 (MergeInsert 도 read-after-write 보장 안 함). SQLite 의 `embedding_records.status` 가 truth 역할 — `search` 가 `WHERE status='committed'` 로 join 해서 partial-write Lance 행 보이지 않음. 프로세스 crash 후 재 ingest 가 idempotent (Lance MergeInsert 가 chunk_id dedupe).
+
+- **`delete_by_chunk_ids` 의 default no-op + Lance override**.
+ **왜**: `VectorStore` trait 의 default impl 가 빈 no-op 이라 test fake / 미래 다른 backend 가 default 그대로 컴파일됨 (behavioural breaking change 없음). Lance 만 override.
+
+- **byte-edit re-ingest 시 SQLite + Lance 양쪽 stale 청소**.
+ **왜**: P7-3 의 hot bug — `assets.workspace_path` UNIQUE 와 `upsert_asset_row` 의 `ON CONFLICT(asset_id)` gap 때문에 byte 가 바뀐 같은 path 의 자산이 ingest 실패. `purge_orphan_at_workspace_path` 가 SQLite 측 stale chunk_id 수집, follow-up PR 이 `VectorStore::delete_by_chunk_ids` 추가해 LanceDB 에서도 같은 chunk_id 삭제. 둘 다 안 청소하면 orphan vector 누적.
+
+- **chat session storage = V005 (V004 와 충돌)**.
+ **왜**: spec PR 가 chat_sessions 를 V004 로 잡았는데 같은 시점 p9-fb-19 가 kv 테이블을 V004 로 가져감. refinery 는 numeric 충돌 = hard fail. p9-fb-17 가 V005 로 시프트, HOTFIXES 기록.
+
+- **`SqliteStore` 가 `JobRepo` + `ChatSessionRepo` 도 구현**.
+ **왜**: 모두 같은 SQLite 파일 안에 있고 같은 `Mutex` 공유. 별 crate 로 분리하면 connection 두 개 필요 → SQLite 가 single-writer 라 contention 만 늘어남. trait 별로는 분리, impl 은 한 store.
+
+- **FTS5 본문 spec verbatim + CI diff-check**.
+ **왜**: tokenizer (`unicode61 remove_diacritics 2`) + trigger 본문이 frozen 설계 §5.5 와 byte-for-byte 동일해야 wire 동작 일치. CI 가 diff 검출.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §5.1 (meta), §5.2 (assets), §5.3 (documents), §5.4 (blocks), §5.5 (chunks + FTS5), §5.6 (embedding_records two-phase), §5.7 (jobs/ingest_runs/answers/eval_runs), §5.7a (chat_sessions/turns), §6.3 (lance table naming), §7.2 (DocumentStore/VectorStore/JobRepo/ChatSessionRepo): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - SQLite store: [`tasks/p1/p1-6-store-sqlite.md`](../../../tasks/p1/p1-6-store-sqlite.md)
+ - FTS5 + V002: [`tasks/p2/p2-1-fts.md`](../../../tasks/p2/p2-1-fts.md)
+ - V003 + LanceDB + two-phase: [`tasks/p3/p3-3-store-vector.md`](../../../tasks/p3/p3-3-store-vector.md)
+ - V004 kv: [`tasks/p9/p9-fb-19-search-cache.md`](../../../tasks/p9/p9-fb-19-search-cache.md)
+ - V005 chat sessions: [`tasks/p9/p9-fb-17-chat-session-storage.md`](../../../tasks/p9/p9-fb-17-chat-session-storage.md)
+- HOTFIXES (P7-3 storage UNIQUE bug + delete_by_chunk_ids follow-up, V004→V005 시프트): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)
diff --git a/docs/components/ui/README.md b/docs/components/ui/README.md
new file mode 100644
index 0000000..590793a
--- /dev/null
+++ b/docs/components/ui/README.md
@@ -0,0 +1,183 @@
+# UI
+
+> 사용자 surface — 두 binary, 둘 다 `kebab-app` facade 만 사용. CLI 가 1:1 subcommand → app fn 매핑, TUI 가 4 패널 (Library / Search / Ask / Inspect) ratatui shell.
+
+## 구성 crate
+
+| Crate | 역할 |
+|-------|------|
+| `kebab-cli` | binary `kebab`. clap subcommand → `kebab-app` fn 1:1. wire schema v1 envelope (`--json`). progress bar (indicatif). Ctrl-C cancel handler. exit codes. |
+| `kebab-tui` | binary `kebab tui` (그리고 라이브러리). ratatui + crossterm. 4 패널 + cheatsheet popup + error overlay + Vim-style mode machine. async search worker + background ingest worker + external editor jump. |
+
+## 구조 (TUI 위주, CLI 는 단순)
+
+```mermaid
+classDiagram
+ class App {
+ config: Config
+ sqlite: Arc~SqliteStore~
+ kebab_app: app::App
+ focus: Pane
+ mode: Mode
+ theme: Theme
+ library: LibraryState
+ search: SearchState
+ ask: AskState
+ inspect: InspectState
+ ingest: Option~IngestState~
+ cheatsheet_visible: bool
+ error_overlay: Option~ErrorOverlay~
+ pending_editor: Option~EditorRequest~
+ search_cache: ...
+ }
+ class Pane {
+ <>
+ Library
+ Search
+ Ask
+ Inspect
+ Jobs
+ }
+ class Mode {
+ <>
+ Normal
+ Insert
+ +auto_for(pane) Mode
+ +label() &str
+ }
+ class LibraryState
+ class SearchState {
+ input: InputBuffer
+ generation: u64
+ worker_thread: Option~JoinHandle~
+ worker_rx: Option~Receiver~
+ }
+ class AskState {
+ input: InputBuffer
+ turns: Vec~Turn~
+ conversation_id: Option~String~
+ last_answer: Option~Answer~
+ }
+ class InspectState
+ class IngestState {
+ cancel: Arc~AtomicBool~
+ rx: Receiver~IngestEvent~
+ partial_counts
+ }
+ class Theme {
+ +from_name("dark"|"light")
+ +style(Role) Style
+ }
+ class InputBuffer {
+ content: String
+ cursor_col: usize
+ push_char/pop_char/clear/take
+ }
+ App --> Pane : focus
+ App --> Mode
+ App --> Theme
+ App --> LibraryState
+ App --> SearchState
+ App --> AskState
+ App --> InspectState
+ App --> IngestState
+ SearchState --> InputBuffer
+ AskState --> InputBuffer
+```
+
+## Data flow — 전체 ratatui run loop
+
+```mermaid
+flowchart LR
+ Start["kebab tui
main"]
+ Setup["enable_raw_mode
+ EnterAlternateScreen
+ Hide cursor"]
+ Loop["run loop"]
+ Tick["매 tick"]
+ Drain["drain progress channel
(ingest worker)"]
+ Poll["search worker poll
(generation match?)"]
+ Render["render pane (focus)
+ overlay (error/cheatsheet)"]
+ Event["crossterm event"]
+ CIntercept["cheatsheet_intercept
(F1 toggle, Esc close)"]
+ MIntercept["mode_intercept
(i/Esc Normal↔Insert)"]
+ Dispatch["pane dispatch
(library/search/ask/inspect)"]
+ EditorReq["EditorRequest enqueue"]
+ EditorSpawn["with_external_program
(suspend → spawn → restore)"]
+ Restore["disable_raw_mode + force_redraw"]
+ Start --> Setup --> Loop
+ Loop --> Tick --> Drain --> Poll --> Render
+ Render --> Event
+ Event --> CIntercept --> MIntercept --> Dispatch
+ Dispatch --> EditorReq --> EditorSpawn -.RAII guard.-> Restore -.-> Render
+ Loop -.quit.-> Setup
+```
+
+## 주요 type / trait / 함수
+
+**`kebab-cli`** (`main.rs`):
+- clap `Cli { config: Option, verbose, debug, json, command: Cmd }` — `--config` 가 모든 subcommand 에 전역.
+- subcommand 별 호출: `init` → `init_workspace`, `ingest` → `ingest_with_config_cancellable`, `search` → `search_with_config`, `ask` → `ask_with_config` 또는 `ask_with_session_with_config`, `list` / `inspect` / `doctor` / `tui` / `reset` → 대응 `*_with_config`.
+- `progress.rs` — `indicatif::ProgressBar` (TTY 시) / non-TTY 한 줄씩 / `--json` line-delimited stdout.
+- `cancel.rs` — `ctrlc` SIGINT handler. 1회 → cancel signal, 2회 → hard exit 130.
+- `wire.rs` — `*.v1` envelope wrap (`ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1` 등). `DoctorReport` 만 자체 schema_version, 다른 도메인 type 은 cli 에서 wrap.
+- exit code: 0 (ok) / 1 (anyhow chain) / 2 (clap usage) / 3 (no-hit signal) / 4 (refusal signal) / 5 (doctor unhealthy) — design §10.
+
+**`kebab-tui`** (`lib.rs` re-export):
+- `App { config, sqlite, kebab_app, focus, mode, theme, library, search, ask, inspect, ingest, cheatsheet_visible, error_overlay, pending_editor, search_cache, ... }` — single-threaded shell state.
+- `Pane` enum (Library / Search / Ask / Inspect / Jobs), `Mode` enum (Normal / Insert) + `Mode::auto_for(pane)` (Library/Inspect/Jobs → Normal, Search/Ask → Insert).
+- `InputBuffer { content, cursor_col }` — wide-char-aware (한글 = 2 col, ASCII = 1 col, combining = 0). `push_char` / `pop_char` / `clear` / `take`.
+- `Theme { from_name, style(Role) }` — 16 Role × 2 palette (dark/light). 모든 pane 의 inline `Style::default().fg(...)` → `theme.style(Role::X)` 격리.
+- `render_*(f, area, app, ...)` per pane — `f: &mut Frame<'_>` (ratatui 0.28 backend-agnostic). cursor caret 은 caret-필요 pane (Search / Ask / Filter) 가 `set_cursor_position(...)` 호출.
+- `handle_key_*(app, key) -> KeyOutcome` per pane — Mode-authoritative dispatch (p9-fb-12 follow-up): Normal 에서 nav/command 키, Insert 에서 typing.
+- `enter_inspect(app, target: InspectTarget, return_to: Pane)` (p9-fb-04) — Library `Enter` (Doc) / Search `o` (Chunk).
+- `with_external_program(&mut TuiTerminal, Command)` (p9-fb-09) — RAII guard 가 atomic suspend/restore.
+- `cheatsheet_intercept(app, key)` (p9-fb-13) — F1 toggle, mode_intercept 보다 먼저 dispatch.
+- `start_ingest / drain_progress / cancel_running_ingest / ready_to_clear / status_line` (p9-fb-03) — Library `r` 가 spawned thread + channel + Esc cancel.
+- `markdown::render(text, &Theme) -> Vec>` (p9-fb-11) — pulldown-cmark 위 inline + block + table + code + heading.
+- `footer_hints(focus, mode, filter_open) -> &'static str` (p9-fb-13 follow-up) — 한국어 동사구 + mode-aware. 첫 fragment 항상 `F1 도움말`.
+
+## 외부 의존
+
+- `kebab-cli` → `kebab-app` (only), `clap`, `indicatif`, `ctrlc`, `serde_json`, `anyhow`. **forbidden** 직접 import: `kebab-store-*` / `kebab-llm-*` / `kebab-search` / `kebab-rag`.
+- `kebab-tui` → `kebab-app` (only) + `kebab-config` + `kebab-core` + `kebab-store-sqlite` (직접 import — App 의 sqlite handle 공유 위함, ChatSessionRepo 호출), `ratatui` (0.28), `crossterm` (0.28), `pulldown-cmark` (0.13), `unicode-width`, `lru`, `anyhow`, `time`.
+- 외부 서비스: 없음 (facade 가 가져옴).
+
+## 핵심 결정
+
+- **UI binary 가 facade 만 → swap 가능**.
+ **왜**: future MCP server / HTTP wrapper 가 같은 contract 위에 build. `kebab-cli` 가 `--json` envelope 으로 wire schema v1 표면 = 외부 통합 (Claude Code skill 등) 이 binary 한 줄 spawn 으로 끝.
+
+- **`kebab-app::App` 한 번 open → CLI subcommand 처리 후 drop**.
+ **왜**: per-invocation cold start 단순. 장수 caller (kebab-tui session) 만 retain → memoized embedder/vector/llm 이득.
+
+- **TUI = single-threaded run loop + 외부 worker thread**.
+ **왜**: ratatui idiomatic. 매 tick render. search / ingest 처럼 50-200ms+ 작업은 별 thread + channel post 로 freeze 회피. main thread 가 매 tick channel drain + apply.
+
+- **search worker = generation counter + stale drop** (p9-fb-08).
+ **왜**: 사용자가 빠르게 타이핑 시 매 keystroke 가 worker spawn. 이전 worker 의 결과가 늦게 도착해도 generation mismatch → silent drop. UI 항상 최신 query 의 결과만 보임.
+
+- **Vim-style Mode machine** (p9-fb-12).
+ **왜**: 텍스트 입력 (`e`/`j`/`k`/`i`) 와 command 키 (`e`=explain, `j`=down, `k`=up, `i`=inspect) 충돌. 도그푸딩에서 "explain" / "javascript" 같은 단어 입력이 mode 안 가지고 깨짐. Normal/Insert 명시. Search/Ask 는 자동 Insert (입력 위주), Library/Inspect/Jobs 는 자동 Normal (nav 위주). `i` 가 universal Normal→Insert toggle (p9-fb-21), Search 의 chunk inspect 는 `i`→`o` rebind (vim "open").
+
+- **CJK column-aware InputBuffer** (p9-fb-10).
+ **왜**: ratatui frame 의 `set_cursor_position(...)` 가 column 단위. byte/char 인덱스 시 한글 1 글자가 1 col 만 차지하는 것처럼 cursor 가 박힘. `unicode-width` 위 wide-char 단위 cursor_col 추적 → caret 정확. backspace 는 모든 pane 이 `String::pop()` 으로 char-aware 이미 안전.
+
+- **Theme module** (p9-fb-14).
+ **왜**: dark/light palette swap 가 inline `fg(Color::*)` 흩어져 있으면 한 군데 빼먹음. 16 Role × 2 Palette exhaustive match → unknown role compile-time fail. config typo 시 dark fallback (panic 안 함).
+
+- **External editor jump = RAII guard** (p9-fb-09).
+ **왜**: ratatui alt-screen + raw mode 가 spawn 사이 깨지면 화면 손상. suspend (LeaveAlt + Show cursor + disable_raw) → spawn → restore (enable_raw + EnterAlt + Hide + clear) 시퀀스를 RAII 로 묶음. 키 핸들러는 enqueue 만, run loop 가 `TuiTerminal` handle 들고 spawn — handle ownership 분리.
+
+- **Multi-turn Ask UI** (p9-fb-16).
+ **왜**: 도그푸딩에서 "이 문서 더 자세히" 같은 follow-up 질문이 standalone single-shot 으로 처리되어 retrieval 부정확. answer area 가 transcript (Q1/A1, Q2/A2, ...). 매 Enter 가 prior turns 를 history 로 worker 에 전달. `Ctrl-L` 로 conversation 초기화.
+
+- **F1 cheatsheet popup** (p9-fb-13).
+ **왜**: 도그푸딩에서 keybinding discoverability 문제. 단축키 도움말 없으면 사용자가 매번 README 검색. spec 의 `?` trigger 가 Library 의 quick-Ask 와 충돌해서 `F1` rebind. mode_intercept 보다 먼저 dispatch — popup 이 mode flip 발동 안 시킴.
+
+## 관련 spec / HOTFIXES
+
+- frozen 설계 §1 (UX scenes), §2.4a (ingest progress wire), §3.7 (SearchHit / DocSummary), §3.8 (Answer / Turn), §8 (boundary), §10 (errors / exit codes): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
+- task spec:
+ - CLI: [`tasks/p0/p0-1-skeleton.md`](../../../tasks/p0/p0-1-skeleton.md) (초기) + 모든 phase 의 wiring task.
+ - TUI: [`tasks/p9/p9-1-tui-library.md`](../../../tasks/p9/p9-1-tui-library.md), [`tasks/p9/p9-2-tui-search.md`](../../../tasks/p9/p9-2-tui-search.md), [`tasks/p9/p9-3-tui-ask.md`](../../../tasks/p9/p9-3-tui-ask.md), [`tasks/p9/p9-4-tui-inspect.md`](../../../tasks/p9/p9-4-tui-inspect.md)
+ - 도그푸딩 후속 (p9-fb-01..21): [`tasks/p9/`](../../../tasks/p9/)
+- HOTFIXES (P9-1 Backend generic 제거, P9-2 workspace_root 인자, P9-3 e/j/k 텍스트 충돌 → mode machine, p9-fb-* 도그푸딩 후속): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)