---
title: "v0.20.0 sub-item 1 bugfix round 3 — plan"
created: 2026-05-27
status: DRAFT
round: 0
spec_path: docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix3-spec.md
parent_spec: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
brief: .omc/reviews/2026-05-27-v0.20-bugfix3-plan-drafter-brief.md
branch: feat/pdf-scanned-ocr
base_head: f763049
step_count: 7
commit_count: 6
estimated_minutes: 60
---

# v0.20.0 sub-item 1 bugfix round 3 — plan

Spec ACCEPT (`2026-05-27-v0.20-sub1-bugfix3-spec.md`, 410 line, 11/11 critic finding 반영) 의 step-level decomposition. 5 bug (#9 / #10 / #11 / #13 / #14) + HOTFIXES + parent spec cross-link 까지 한 round 에서 처리.

## §0 Overview

### §0.1 Scope

| Bug | Severity | Surface | Type |
|-----|----------|---------|------|
| #9  | critical | `schema.v1.capabilities` | wire field correction (false → true) |
| #10 | medium   | `error.v1` | additive error code `config_not_found` |
| #11 | critical UX | `config.pdf.ocr.request_timeout_secs` default | numeric default change (600 → 60) |
| #13 | medium   | `schema.v1.models` | additive array fields (backward compat) |
| #14 | minor UX | `kebab search` / `kebab ask` input validation | additive error code path (`invalid_input`) |
| —   | —        | `tasks/HOTFIXES.md` + parent spec | docs handoff for Bug #11 deviation |

### §0.2 Strategy

- **Per-bug commit boundary** (option A): 한 commit 당 한 bug 만 — revert / bisect 가 정확. Step 6 만 doc-only.
- **wire schema = additive minor**. Bug #13 `models` 의 신규 두 field 는 optional. 기존 client 영향 0. spec §3.4 와 일치.
- **parent spec frozen**: text 변경 0. inline HTML 주석 cross-link 만. HOTFIXES.md 가 live source of truth.
- **subagent skip**: in-session direct execution. spec §7 의 worker protocol 준수.
- **regression budget**: 기존 workspace test 1350 + 본 round 새 +7 ≥ 1357 test, 모두 green.

### §0.3 Environment

```bash
cd /home/altair823/kebab
export CARGO_TARGET_DIR=/build/out/cargo-target/target
git status      # working tree clean expected
git rev-parse HEAD   # f763049
```

`-j 4` default (workspace memory budget). `-j 1` 은 OOM fallback only — full workspace integration run 일 때만.

---

## §1 Step table

| Step | Subject | Files | New tests | Commit |
|------|---------|-------|-----------|--------|
| 1 | Bug #9 capabilities flip | `crates/kebab-app/src/schema.rs` | 2 unit | `fix(app): flip streaming_ask + single_file_ingest capabilities to actual surface (Bug #9)` |
| 2 | Bug #10 config_not_found error | `crates/kebab-config/src/lib.rs`, `crates/kebab-app/src/error_signal.rs`, `crates/kebab-app/src/error_wire.rs`, `crates/kebab-app/src/lib.rs` (re-export) | 1 unit + 2 integration | `fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)` |
| 3 | Bug #11 OCR timeout 60s | `crates/kebab-config/src/lib.rs` | 1 unit | `fix(config): pdf.ocr.request_timeout_secs default 600 → 60 per dogfood evidence (Bug #11)` |
| 4 | Bug #13 active_parsers + active_chunkers (additive) | `crates/kebab-store-sqlite/src/store.rs` (또는 lib.rs), `crates/kebab-app/src/schema.rs`, `docs/wire-schema/v1/schema.schema.json`, `integrations/claude-code/kebab/SKILL.md` | 2 integration | `feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)` |
| 5 | Bug #14 empty query invalid_input | `crates/kebab-cli/src/main.rs` | 2 integration | `fix(cli): empty query emits error.v1 invalid_input for search + ask (Bug #14)` |
| 6 | HOTFIXES + parent spec cross-link | `tasks/HOTFIXES.md`, `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` | 0 | `docs(spec): HOTFIXES entry + parent spec cross-link for Bug #11 timeout deviation` |
| 7 | Final sanity (no commit) | — | — | n/a |

**Total**: 7 step, 6 commit, 8 new test (3 unit + 5 integration). Reaches spec §5 AC-1 ~ AC-10.

---

## §2 Per-step detail

### Step 1 — Bug #9 capabilities flip

#### §2.1.1 Files affected

- `crates/kebab-app/src/schema.rs:137-151` (`capabilities_snapshot()`).
- `crates/kebab-app/src/schema.rs` (`#[cfg(test)] mod` 또는 새 `mod tests_capabilities` — 기존 `mod tests_stats_ext` 와 동등 위치).

#### §2.1.2 Action

`capabilities_snapshot()` body 의 두 줄만 변경:

```diff
 fn capabilities_snapshot() -> Capabilities {
     Capabilities {
         json_mode: true,
         ingest_progress: true,
         ingest_cancellation: true,
         rag_multi_turn: true,
         search_cache: true,
         incremental_ingest: true,
-        streaming_ask: false,
+        streaming_ask: true,
         http_daemon: false,
         mcp_server: true,
-        single_file_ingest: false,
+        single_file_ingest: true,
         bulk_search: true,
     }
 }
```

`http_daemon: false` 는 보존 — 별도 sub-item 의 non-impl. spec §3.1 의 결정과 일치.

#### §2.1.3 New tests

`crates/kebab-app/src/schema.rs` 의 `#[cfg(test)]` 영역에 unit test 2 개 추가:

```rust
#[test]
fn capabilities_streaming_ask_matches_cli_surface() {
    // Bug #9: kebab ask --stream 가 answer_event.v1 ndjson 정상 emit (191 event 검증) →
    // capabilities.streaming_ask 가 true 여야 함.
    let caps = super::capabilities_snapshot();
    assert!(caps.streaming_ask, "streaming_ask must be true (Bug #9)");
}

#[test]
fn capabilities_single_file_ingest_matches_cli_surface() {
    // Bug #9: kebab ingest-file <path> + kebab ingest-stdin --title <T> 양쪽 모두
    // ingest_report.v1 정상 emit → capabilities.single_file_ingest 가 true 여야 함.
    let caps = super::capabilities_snapshot();
    assert!(caps.single_file_ingest, "single_file_ingest must be true (Bug #9)");
}
```

추가로 `capabilities_snapshot` 가 `pub(crate)` 또는 module-internal 일 경우 `super::` path 로 접근. private 라면 같은 module 의 child mod 에서 호출 가능.

#### §2.1.4 Per-step acceptance

```bash
cargo test -p kebab-app capabilities_streaming_ask_matches_cli_surface -j 4
cargo test -p kebab-app capabilities_single_file_ingest_matches_cli_surface -j 4
cargo test -p kebab-app schema -j 4   # 기존 schema_report.rs integration 도 green 유지
cargo clippy -p kebab-app --all-targets -- -D warnings
```

기존 `mod tests_stats_ext` 의 `stats_includes_breakdowns_and_bytes_on_fresh_corpus` (schema_with_config 경유) 가 `streaming_ask`/`single_file_ingest` 를 assert 안 함 — regression 없음.

#### §2.1.5 Commit

```bash
git add crates/kebab-app/src/schema.rs
git commit -m "$(cat <<'EOF'
fix(app): flip streaming_ask + single_file_ingest capabilities to actual surface (Bug #9)

capabilities_snapshot() 가 streaming_ask + single_file_ingest 를 hardcoded false 로
보고했으나 실제 구현은 v0.20 final-dogfood 에서 production-grade:
- kebab ask --stream → answer_event.v1 ndjson 191 event 정상 emit
- kebab ingest-file <path> / kebab ingest-stdin --title <T> → ingest_report.v1 정상

MCP host + Claude Code skill 등 agent 가 schema.capabilities 로 routing 결정 시
false negative → 사용자가 실제 동작 feature 를 사용 불가능하다고 오인.

http_daemon 은 false 유지 (별도 sub-item 의 non-impl).

EOF
)"
```

---

### Step 2 — Bug #10 ConfigNotFound + classify arm

#### §2.2.1 Files affected

1. `crates/kebab-config/src/lib.rs`
   - 19-22 line 의 `ConfigInvalid` 정의 옆에 `ConfigNotFound` 추가.
   - 688-722 line 의 `Config::load` 안 `Some(_) => Self::defaults(),` arm 을 `Some(_) => Err(...)` 로 변경.
2. `crates/kebab-app/src/error_signal.rs`
   - `pub use kebab_config::ConfigNotFound;` 추가 (기존 `ConfigInvalid` 와 동등 pattern).
3. `crates/kebab-app/src/error_wire.rs`
   - `classify` 안 `ConfigInvalid` arm 다음에 `ConfigNotFound` arm 추가.
4. `crates/kebab-app/src/lib.rs`
   - `pub use kebab_config::ConfigInvalid;` 옆에 `pub use kebab_config::ConfigNotFound;` 추가 (기존 14 line pattern).

#### §2.2.2 Action

**(a) `crates/kebab-config/src/lib.rs` — error type 추가** (line ~25, `ConfigInvalid` 직후):

```rust
/// p20-bugfix3 Bug #10: explicit `--config <path>` 가 missing 시 silent
/// fallback to defaults 대신 fail-fast. `kebab-app::error_wire::classify`
/// 가 downcast → `code: "config_not_found"` ErrorV1.
#[derive(Debug, thiserror::Error)]
#[error("config file does not exist: {path}")]
pub struct ConfigNotFound {
    pub path: PathBuf,
}
```

**(b) `crates/kebab-config/src/lib.rs:688-722` — `Config::load` 분기 수정**:

```diff
 pub fn load(path: Option<&Path>) -> anyhow::Result<Self> {
     let from_disk = match path {
         Some(p) if p.exists() => Self::from_file(p)?,
-        Some(_) => Self::defaults(),
+        Some(p) => {
+            // Bug #10: explicit --config 가 missing → silent default fallback 금지.
+            return Err(anyhow::Error::new(ConfigNotFound {
+                path: p.to_path_buf(),
+            }));
+        }
         None => {
             let p = Self::xdg_config_path();
             ...
```

상대경로 cover: `Path::exists()` 는 cwd-relative — spec §6 R-1 해결 (별도 작업 0).

**(c) `crates/kebab-app/src/error_signal.rs` — re-export**:

```rust
pub use kebab_config::ConfigNotFound;
```

(기존 `ConfigInvalid` re-export 와 동등 위치. 같은 file 안에서 `use kebab_config::ConfigInvalid;` 이미 있다면 그 옆.)

**(d) `crates/kebab-app/src/error_wire.rs::classify`** — `ConfigInvalid` arm 직후:

```rust
if let Some(s) = err.downcast_ref::<ConfigNotFound>() {
    return ErrorV1 {
        schema_version: ERROR_V1_ID.to_string(),
        code: "config_not_found".to_string(),
        message: s.to_string(),
        details: json!({
            "path": s.path.to_string_lossy(),
        }),
        hint: Some(
            "verify --config <path>; pass an existing toml file or omit --config to use XDG default"
                .to_string(),
        ),
    };
}
```

상단 `use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed};` 에 `ConfigNotFound` 추가.

**(e) `crates/kebab-app/src/lib.rs:14` — public re-export**:

```rust
pub use kebab_config::{ConfigInvalid, ConfigNotFound};
```

#### §2.2.3 New tests

**(a) `crates/kebab-config/src/lib.rs` — unit test (기존 `tests` mod 안)**:

```rust
#[test]
fn config_load_explicit_nonexistent_path_returns_config_not_found() {
    // Bug #10: --config /tmp/nonexistent.toml → silent fallback 금지.
    let p = std::path::Path::new("/tmp/__kebab_bugfix3_nonexistent.toml");
    assert!(!p.exists(), "test precondition: path must not exist");

    let err = Config::load(Some(p)).expect_err("expected ConfigNotFound");
    let signal = err
        .downcast_ref::<ConfigNotFound>()
        .expect("from_load error should downcast to ConfigNotFound");
    assert_eq!(signal.path, p.to_path_buf());
}
```

**(b) `crates/kebab-cli/tests/cli_error_wire.rs` 또는 신규 `crates/kebab-cli/tests/cli_config_not_found.rs` — integration test 2 개**:

```rust
use std::process::Command;
use serde_json::Value;

fn kebab_bin() -> String {
    env!("CARGO_BIN_EXE_kebab").to_string()
}

#[test]
fn invalid_config_path_emits_error_v1_with_nonzero_exit() {
    let absent = "/tmp/__kebab_bugfix3_absolute_nonexistent.toml";
    assert!(!std::path::Path::new(absent).exists());

    let out = Command::new(kebab_bin())
        .args(["search", "rust", "--config", absent, "--json"])
        .output()
        .expect("spawn kebab");

    assert_ne!(out.status.code(), Some(0), "exit must be nonzero on missing --config");
    let stderr = String::from_utf8_lossy(&out.stderr);
    let last_line = stderr.lines().last().expect("error.v1 line on stderr");
    let v: Value = serde_json::from_str(last_line)
        .unwrap_or_else(|e| panic!("expected error.v1 ndjson on stderr: {e}\nstderr={stderr}"));
    assert_eq!(v["schema_version"], "error.v1");
    assert_eq!(v["code"], "config_not_found");
    assert!(v["hint"].is_string(), "hint must be present");
}

#[test]
fn invalid_relative_config_path_emits_config_not_found() {
    // Bug #10 spec §6 R-1: relative path 도 cwd-relative 로 cover.
    let tmp = tempfile::tempdir().unwrap();
    let out = Command::new(kebab_bin())
        .args(["search", "rust", "--config", "nonexistent-rel.toml", "--json"])
        .current_dir(tmp.path())
        .output()
        .expect("spawn kebab");

    assert_ne!(out.status.code(), Some(0));
    let stderr = String::from_utf8_lossy(&out.stderr);
    let last_line = stderr.lines().last().expect("error.v1 line");
    let v: Value = serde_json::from_str(last_line).expect("ndjson");
    assert_eq!(v["code"], "config_not_found");
}
```

기존 `cli_error_wire.rs` 의 ConfigInvalid integration test 패턴을 참고 (existing test 그대로 green 유지 — fail-fast 가 `ConfigInvalid` (file 존재 + parse 실패) 와 별개 path).

#### §2.2.4 Per-step acceptance

```bash
cargo test -p kebab-config config_load_explicit_nonexistent_path -j 4
cargo test -p kebab-cli invalid_config_path_emits_error_v1_with_nonzero_exit -j 4
cargo test -p kebab-cli invalid_relative_config_path_emits_config_not_found -j 4
cargo test -p kebab-config -j 4    # 기존 18 test 전수 green
cargo test -p kebab-app error_wire -j 4   # 기존 classify test 전수 green (ConfigInvalid 등)
cargo clippy -p kebab-config -p kebab-app -p kebab-cli --all-targets -- -D warnings
```

`Config::load` 의 `None → XDG default` path 는 변경 0 — `kebab doctor` (config 없는 fresh clone) regression 없음.

#### §2.2.5 Commit

```bash
git add crates/kebab-config/src/lib.rs \
        crates/kebab-app/src/error_signal.rs \
        crates/kebab-app/src/error_wire.rs \
        crates/kebab-app/src/lib.rs \
        crates/kebab-cli/tests/
git commit -m "$(cat <<'EOF'
fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)

이전: `kebab search "rust" --config /tmp/nonexistent.toml --json` 가 exit=0 +
`{"hits":[]}` silent fallback to XDG default. typo / wrong path 가 0-hit 으로만
surface — debugging nightmare.

이후: kebab_config::ConfigNotFound thiserror::Error 추가, Config::load 의
`Some(p) if !p.exists()` arm 이 anyhow::Error::new(ConfigNotFound { path })
return. kebab_app::error_wire::classify 가 downcast → ErrorV1 code=config_not_found,
hint, details.path 채워서 stderr 에 ndjson 으로 emit.

R-1 (relative path): std::path::Path::exists() 는 cwd-relative — 별도 작업 없이
absolute + relative 모두 cover. integration test 두 개로 검증.

EOF
)"
```

---

### Step 3 — Bug #11 OCR timeout 60s

#### §2.3.1 Files affected

- `crates/kebab-config/src/lib.rs:477` (`default_pdf_ocr_request_timeout_secs`).
- `crates/kebab-config/src/lib.rs` `#[cfg(test)] mod tests` (또는 적합한 위치) — 신규 unit test 추가.

#### §2.3.2 Action

```diff
-fn default_pdf_ocr_request_timeout_secs() -> u64 { 600 }
+/// PDF OCR per-page request timeout 의 기본값.
+/// 6-32s 가 정상 throughput; 60s 초과는 Ollama 다운 / 매우 dense·고해상도 page 의 신호.
+/// `config.toml` 의 `[pdf.ocr] request_timeout_secs = N` 로 override.
+///
+/// HOTFIXES 2026-05-27 (Bug #11): metro-korea.pdf dogfood 에서 page 8/13 모두
+/// 기존 600s default 까지 완전 timeout (`chars: 0, skipped: true` × 20분 cost) →
+/// 60s 로 하향. parent spec §1000 / §1628 OQ-1 (CPU 환경 105s 의 5x 여유) 가
+/// 가정한 "page 당 평균 105s" 보다 실측 cloud GPU Ollama 가 6-32s 로 훨씬 빠름.
+fn default_pdf_ocr_request_timeout_secs() -> u64 { 60 }
```

기존 470 line 의 `request_timeout_secs: default_pdf_ocr_request_timeout_secs(),` 는 동일 함수 호출이라 추가 변경 0.

#### §2.3.3 New tests

`crates/kebab-config/src/lib.rs` 의 `#[cfg(test)] mod tests`:

```rust
#[test]
fn pdf_ocr_request_timeout_default_is_60s() {
    // Bug #11 (dogfood 2026-05-27): default 600s → 60s.
    let cfg = PdfOcrCfg::defaults();
    assert_eq!(
        cfg.request_timeout_secs, 60,
        "pdf.ocr.request_timeout_secs default must be 60s (Bug #11, HOTFIXES 2026-05-27)"
    );
}
```

기존 unit test 중 600 magic number 를 검증하는 항목이 있다면 동일 commit 안에서 60 으로 갱신. (verify: `grep -rn "request_timeout_secs.*600\|600.*request_timeout_secs" crates/kebab-config/src/` — 발견 시 그 test 만 expect 값 갱신, 새 unit test 와 같은 의미라면 기존 test 만 갱신하고 신규 test 생략 가능. 본 plan 은 spec ACCEPT 의 보수적 선택: 신규 test 도 추가해 unique name 으로 보존.)

#### §2.3.4 Per-step acceptance

```bash
cargo test -p kebab-config pdf_ocr_request_timeout_default_is_60s -j 4
cargo test -p kebab-config -j 4   # 18 test 전수 green; 만일 기존 test 가 600 expect 면 같은 commit 에서 갱신
cargo clippy -p kebab-config --all-targets -- -D warnings
```

`PdfOcrCfg::defaults()` 의 다른 field 는 변경 0 — `max_pixels` (2048), `valid_ratio_threshold` (0.5), `min_char_count` (20), `lang_hint` (`"kor"`) 보존.

#### §2.3.5 Commit

```bash
git add crates/kebab-config/src/lib.rs
git commit -m "$(cat <<'EOF'
fix(config): pdf.ocr.request_timeout_secs default 600 → 60 per dogfood evidence (Bug #11)

metro-korea.pdf v0.20 final-dogfood (2026-05-27):
- page 8 + page 13 양쪽 모두 600s default 까지 완전 timeout
  (`ms: 600000, chars: 0, skipped: true`)
- 결과: 본문 indexed 안 됨 + page 당 20분 cost 낭비

cloud GPU Ollama 의 실측 per-page throughput 는 6-32s (parent spec 가정 105s 보다
훨씬 빠름). 60s 면 production-friendly upper-bound. dense/고해상도 page 는
config.toml override (`[pdf.ocr] request_timeout_secs = N`) 로 user 가 늘릴 수
있음 — Step 6 에서 HOTFIXES + parent spec cross-link.

EOF
)"
```

---

### Step 4 — Bug #13 active_parsers + active_chunkers (additive minor)

#### §2.4.1 Files affected

1. `crates/kebab-store-sqlite/src/store.rs` (또는 lib.rs — `impl SqliteStore` 의 다른 fetch_* method 와 같은 file).
2. `crates/kebab-app/src/schema.rs` (`Models` struct 정의 위치 — `kebab-app` 안 `pub struct Models` 검색해 동일 file 안에 추가).
3. `docs/wire-schema/v1/schema.schema.json` — `models.properties` 에 두 array 추가.
4. `integrations/claude-code/kebab/SKILL.md` — `models` description 갱신.
5. `crates/kebab-app/tests/schema_report.rs` (또는 신규 file) — integration test 2개.

#### §2.4.2 Action

**(a) `crates/kebab-store-sqlite/src/store.rs` 의 `impl SqliteStore`** — 신규 method 2개:

```rust
/// p20-bugfix3 Bug #13: schema.v1.models.active_parsers 의 source.
/// `documents.parser_version` 컬럼의 DISTINCT 값을 정렬해 반환.
/// 빈 corpus → 빈 Vec.
pub fn fetch_distinct_parser_versions(&self) -> anyhow::Result<Vec<String>> {
    let conn = self.conn()?;
    let mut stmt = conn.prepare(
        "SELECT DISTINCT parser_version FROM documents
          WHERE parser_version IS NOT NULL AND parser_version != ''
          ORDER BY parser_version",
    )?;
    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
    let mut out = Vec::new();
    for r in rows {
        out.push(r?);
    }
    Ok(out)
}

/// p20-bugfix3 Bug #13: schema.v1.models.active_chunkers 의 source.
/// `chunks.chunker_version` 컬럼의 DISTINCT 값을 정렬해 반환.
pub fn fetch_distinct_chunker_versions(&self) -> anyhow::Result<Vec<String>> {
    let conn = self.conn()?;
    let mut stmt = conn.prepare(
        "SELECT DISTINCT chunker_version FROM chunks
          WHERE chunker_version IS NOT NULL AND chunker_version != ''
          ORDER BY chunker_version",
    )?;
    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
    let mut out = Vec::new();
    for r in rows {
        out.push(r?);
    }
    Ok(out)
}
```

note: `self.conn()` 가 `SqliteStore` 의 기존 connection accessor 가 아니면 같은 file 안 기존 method 의 connection 획득 pattern 을 그대로 사용 (`code_lang_breakdown`, `repo_breakdown`, `corpus_revision` 가 참조 모델).

**(b) `crates/kebab-app/src/schema.rs` — `Models` struct 확장**:

```diff
 pub struct Models {
     pub parser_version: String,
     pub chunker_version: String,
+    /// v0.20.1+ (Bug #13). Corpus 안 활성 parser version 전체.
+    /// 빈 corpus → empty Vec. backward compat: `parser_version` field 보존.
+    #[serde(default)]
+    pub active_parsers: Vec<String>,
+    /// v0.20.1+ (Bug #13). Corpus 안 활성 chunker version 전체.
+    /// 빈 corpus → empty Vec.
+    #[serde(default)]
+    pub active_chunkers: Vec<String>,
     pub embedding_version: String,
     pub prompt_template_version: String,
     pub index_version: String,
     pub corpus_revision: u64,
 }
```

`#[serde(default)]` 는 v0.20.0 이전 client 가 schema.v1 deserialize 시 backward compat (없는 field → `Vec::new()`).

**(c) `crates/kebab-app/src/schema.rs:192-207` — `collect_models` 갱신**:

```diff
 fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> Models {
+    let active_parsers = store.fetch_distinct_parser_versions().unwrap_or_default();
+    let active_chunkers = store.fetch_distinct_chunker_versions().unwrap_or_default();
+
     Models {
         parser_version: kebab_parse_md::PARSER_VERSION.to_string(),
         chunker_version: cfg.chunking.chunker_version.clone(),
+        active_parsers,
+        active_chunkers,
         embedding_version: cfg.models.embedding.model.clone(),
         prompt_template_version: cfg.rag.prompt_template_version.clone(),
         index_version: kebab_store_vector::INDEX_VERSION_STR.to_string(),
         corpus_revision: store.corpus_revision(),
     }
 }
```

R-3 (spec §6) 해결: `collect_models` 가 매 schema 호출마다 재계산 — cache 없음, stale 위험 없음.

**markdown PARSER_VERSION 보존**: 기존 `parser_version` field 는 `kebab_parse_md::PARSER_VERSION` (markdown default) 그대로 — backward compat. spec §3.4 의 결정과 일치.

**(d) `docs/wire-schema/v1/schema.schema.json` — `models.properties` 갱신**:

```diff
     "models": {
       "type": "object",
       "required": [
         "parser_version", "chunker_version", "embedding_version",
         "prompt_template_version", "index_version", "corpus_revision"
       ],
       "properties": {
         "parser_version": { "type": "string" },
         "chunker_version": { "type": "string" },
+        "active_parsers": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "v0.20.1+ (Bug #13). 활성 parser version 전체 (DISTINCT, ORDER BY). 빈 corpus → []. backward-compat: optional, 기존 client 무영향."
+        },
+        "active_chunkers": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "v0.20.1+ (Bug #13). 활성 chunker version 전체 (DISTINCT, ORDER BY). 빈 corpus → []."
+        },
         "embedding_version": { "type": "string" },
         "prompt_template_version": { "type": "string" },
         "index_version": { "type": "string" },
         "corpus_revision": { "type": "integer", "minimum": 0 }
       }
     },
```

`required` array 에는 추가하지 않음 — additive minor 의 정의.

**(e) `integrations/claude-code/kebab/SKILL.md:155` — description 갱신**:

```diff
-Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), `stats` (...
+Returns `schema.v1`: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis + v0.20.1 `active_parsers` / `active_chunkers` arrays for multi-version corpora), `stats` (...
```

`description` frontmatter 는 generic 유지 — per-user trigger keyword 는 user 의 local copy 만.

#### §2.4.3 New tests

`crates/kebab-app/tests/schema_report.rs` (또는 신규 file `crates/kebab-app/tests/schema_active_versions.rs`):

```rust
use kebab_app::schema_with_config;
use kebab_config::Config;

#[test]
fn schema_models_active_arrays_empty_on_empty_corpus() {
    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config::defaults();
    cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();

    let store = kebab_store_sqlite::SqliteStore::open(&cfg).unwrap();
    store.run_migrations().unwrap();
    drop(store);

    let s = schema_with_config(&cfg).unwrap();
    assert!(s.models.active_parsers.is_empty(), "empty corpus → no parsers");
    assert!(s.models.active_chunkers.is_empty(), "empty corpus → no chunkers");
    // backward compat: 기존 단일 field 는 markdown default 보존.
    assert_eq!(s.models.parser_version, kebab_parse_md::PARSER_VERSION);
}

#[test]
fn schema_emits_active_parsers_and_chunkers_array_after_mixed_ingest() {
    // markdown + (선택적) code ingest 후 active_parsers/chunkers 가 비어있지 않음.
    // 본 test 는 kebab-app 의 ingest_with_config + schema_with_config 조합 — 기존
    // ingest_lexical.rs / code_ingest_smoke.rs 의 helper fixture 재활용 가능.
    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config::defaults();
    cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
    cfg.workspace.root = {
        let kb = dir.path().join("kb");
        std::fs::create_dir_all(&kb).unwrap();
        std::fs::write(kb.join("a.md"), "# A\nhello\n").unwrap();
        kb.to_string_lossy().into_owned()
    };

    // Minimal ingest — markdown only 면 active_parsers = ["md-frontmatter-v2"]
    // (또는 PARSER_VERSION 의 string label) 1 entry.
    kebab_app::ingest_with_config(&cfg, false).unwrap();

    let s = schema_with_config(&cfg).unwrap();
    assert!(!s.models.active_parsers.is_empty(), "active_parsers populated after ingest");
    assert!(!s.models.active_chunkers.is_empty(), "active_chunkers populated after ingest");
    // ORDER BY → sorted (lex order).
    let mut sorted = s.models.active_parsers.clone();
    sorted.sort();
    assert_eq!(s.models.active_parsers, sorted, "active_parsers must be sorted");
}
```

note: `kebab_app::ingest_with_config` 정확한 시그니처 (`fn(cfg: &Config, summary_only: bool)` 또는 `fn(scope: SourceScope, summary_only: bool)`) 는 기존 `ingest_lexical.rs` 의 helper 와 동일 pattern 으로 — executor 가 in-tree resolution.

#### §2.4.4 Per-step acceptance

```bash
cargo test -p kebab-store-sqlite fetch_distinct -j 4   # 신규 store method (있으면)
cargo test -p kebab-app schema_models_active_arrays_empty_on_empty_corpus -j 4
cargo test -p kebab-app schema_emits_active_parsers_and_chunkers_array_after_mixed_ingest -j 4
cargo test -p kebab-app schema -j 4   # 기존 schema_report.rs 전수 green (특히 stats_includes_*)
cargo clippy -p kebab-store-sqlite -p kebab-app --all-targets -- -D warnings

# JSON schema lint (additive minor check)
python3 -c "import json; json.load(open('docs/wire-schema/v1/schema.schema.json'))"
```

`stats_includes_breakdowns_and_bytes_on_fresh_corpus` 가 `s.models` 를 assert 안 함 — regression 없음. backward compat: 기존 `parser_version` / `chunker_version` 값 보존.

#### §2.4.5 Commit

```bash
git add crates/kebab-store-sqlite/src/ \
        crates/kebab-app/src/schema.rs \
        crates/kebab-app/tests/ \
        docs/wire-schema/v1/schema.schema.json \
        integrations/claude-code/kebab/SKILL.md
git commit -m "$(cat <<'EOF'
feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)

이전: schema.v1.models 가 parser_version / chunker_version 단일 값만 보고 →
multi-medium corpus (md + pdf + code Rust/Python + dockerfile + k8s + manifest)
의 version cascade audit 누락 risk.

이후: additive minor — Models struct 에 active_parsers + active_chunkers Vec<String>
추가. backward compat: 기존 단일 field 보존 (markdown default), 신규 array 는
optional (#[serde(default)] + JSON schema required 미포함).

source:
- kebab_store_sqlite::fetch_distinct_parser_versions() 가
  documents.parser_version DISTINCT + ORDER BY 반환.
- fetch_distinct_chunker_versions() 가 chunks.chunker_version 동일 pattern.
- collect_models 가 매 schema 호출마다 재계산 (cache 없음 — R-3 자동 해결).

wire schema additive only — 메이저 bump 불필요. v0.20.1 minor 로 충분.
integrations/claude-code/kebab/SKILL.md 동기 갱신.

EOF
)"
```

---

### Step 5 — Bug #14 empty query (search + ask)

#### §2.5.1 Files affected

- `crates/kebab-cli/src/main.rs:818-826` (search arm 의 query_text 해석부).
- `crates/kebab-cli/src/main.rs:990` 부근 (ask arm 의 Config::load 직후 — query 변수가 이미 `&String` available).
- `crates/kebab-cli/tests/` — 신규 integration test 2개 (또는 `cli_error_wire.rs` 안 추가).

#### §2.5.2 Action

**(a) search arm** (line 821-826):

```diff
             // p9-fb-42: bulk mode requires no query; single-query mode requires query.
             let query_text = match query.as_ref() {
-                Some(q) => q.clone(),
+                Some(q) if q.trim().is_empty() => {
+                    return Err(anyhow::Error::new(kebab_app::StructuredError(
+                        kebab_app::ErrorV1 {
+                            schema_version: kebab_app::ERROR_V1_ID.to_string(),
+                            code: "invalid_input".to_string(),
+                            message: "query is empty; provide a non-empty search term or use --bulk".into(),
+                            details: serde_json::Value::Null,
+                            hint: Some("e.g. `kebab search 'rust async'` or `kebab search --bulk < queries.ndjson`".into()),
+                        },
+                    )));
+                }
+                Some(q) => q.clone(),
                 None => {
                     return Err(anyhow::anyhow!("query is required unless --bulk is set"));
                 }
             };
```

`--bulk` mode 우선 — 기존 line 730 의 `if *bulk { ... return Ok(()); }` 가 먼저라 empty query check 가 영향 0.

**(b) ask arm** (line 990 의 `let cfg = ...` 직후):

```diff
         } => {
             let cfg = kebab_config::Config::load(cli.config.as_deref())?;
+            if query.trim().is_empty() {
+                return Err(anyhow::Error::new(kebab_app::StructuredError(
+                    kebab_app::ErrorV1 {
+                        schema_version: kebab_app::ERROR_V1_ID.to_string(),
+                        code: "invalid_input".to_string(),
+                        message: "query is empty; provide a non-empty prompt".into(),
+                        details: serde_json::Value::Null,
+                        hint: Some("e.g. `kebab ask \"explain this code\"`".into()),
+                    },
+                )));
+            }
             if *stream {
```

`query: String` (Some 강제 — line 206) 라 `.trim()` 직접 호출 가능.

#### §2.5.3 New tests

`crates/kebab-cli/tests/cli_empty_query.rs` (신규) 또는 `cli_error_wire.rs` 안:

```rust
use std::process::Command;
use serde_json::Value;

fn kebab_bin() -> String {
    env!("CARGO_BIN_EXE_kebab").to_string()
}

fn parse_error_v1(stderr: &str) -> Value {
    let last = stderr.lines().last().expect("stderr ndjson");
    serde_json::from_str(last).unwrap_or_else(|e| panic!("expected ndjson: {e}\n{stderr}"))
}

#[test]
fn search_empty_query_emits_invalid_input() {
    for q in ["", "   "] {
        let out = Command::new(kebab_bin())
            .args(["search", q, "--json"])
            .output()
            .expect("spawn");
        assert_ne!(out.status.code(), Some(0), "empty/whitespace query must fail: {q:?}");
        let stderr = String::from_utf8_lossy(&out.stderr);
        let v = parse_error_v1(&stderr);
        assert_eq!(v["schema_version"], "error.v1");
        assert_eq!(v["code"], "invalid_input", "stderr={stderr}");
    }
}

#[test]
fn ask_empty_query_emits_invalid_input() {
    let out = Command::new(kebab_bin())
        .args(["ask", "", "--json"])
        .output()
        .expect("spawn");
    assert_ne!(out.status.code(), Some(0));
    let stderr = String::from_utf8_lossy(&out.stderr);
    let v = parse_error_v1(&stderr);
    assert_eq!(v["code"], "invalid_input");
}
```

#### §2.5.4 Per-step acceptance

```bash
cargo test -p kebab-cli search_empty_query_emits_invalid_input -j 4
cargo test -p kebab-cli ask_empty_query_emits_invalid_input -j 4
cargo test -p kebab-cli -j 4   # 기존 cli_error_wire / cli_help_smoke / ... 전수 green
cargo clippy -p kebab-cli --all-targets -- -D warnings
```

`--bulk < ndjson` 의 empty stdin path 는 spec §2.2 의 별도 case — 본 fix 범위 외 (`bulk` arm 이 query 무시).

#### §2.5.5 Commit

```bash
git add crates/kebab-cli/src/main.rs crates/kebab-cli/tests/
git commit -m "$(cat <<'EOF'
fix(cli): empty query emits error.v1 invalid_input for search + ask (Bug #14)

이전: `kebab search "" --json` / `kebab search "  " --json` / `kebab ask "" --json`
모두 exit=0 + silent 0 hit (search) 또는 LLM 빈 prompt round-trip (ask). user
mistake (typo, shell expansion 실수) 가 silent → debugging 비용.

이후: 양쪽 arm 에서 `query.trim().is_empty()` → kebab_app::StructuredError
(ErrorV1, code=invalid_input, hint 포함). exit=2 (StructuredError → 기존
exit_code() 의 generic non-zero path).

--bulk mode 는 영향 0 (bulk arm 이 query 무시).

EOF
)"
```

---

### Step 6 — HOTFIXES + parent spec cross-link (Bug #11 deviation)

#### §2.6.1 Files affected

- `tasks/HOTFIXES.md` — dated entry append.
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` — inline HTML 주석 1 줄 (PDF OCR config 또는 OQ-1 의 timeout 600 언급 위치 가까이).

#### §2.6.2 Action

**(a) `tasks/HOTFIXES.md` — append dated subsection**:

기존 HOTFIXES.md "How to add an entry" 섹션 직전에 5-field 형식으로 추가:

```markdown
## 2026-05-27 — PDF OCR `request_timeout_secs` default 600s → 60s (Bug #11)

**Discovered**: v0.20.0 final dogfood (2026-05-27), metro-korea.pdf 의 page 8 + 13.

**Symptom**: 두 page 모두 `kebab ingest` 가 600s 까지 완전 timeout (`ms: 600000, chars: 0, skipped: true`). 본문 indexed 안 됨, page 당 20분 cost 낭비, user 가 ingest 완료 signal 못 받음.

**Root cause**: `default_pdf_ocr_request_timeout_secs() = 600` (parent spec `2026-04-27-kebab-final-form-design.md` §1000 + §1628 OQ-1 의 "CPU 환경 105s 의 5x 여유" 가정). 실측 cloud GPU Ollama 의 per-page throughput 는 6-32s — 600s 까지 가야 timeout 이라면 Ollama 다운 상태가 사실상 확실. 600s 가 fail-fast 신호로 작동 안 함.

**Fix** (v0.20.0 bugfix3 round 3, branch `feat/pdf-scanned-ocr`):
- `crates/kebab-config/src/lib.rs:477` `default_pdf_ocr_request_timeout_secs() = 60`.
- Doc-comment 보강 — 6-32s 정상 throughput, 60s 초과는 Ollama 다운 / 매우 dense·고해상도 page 신호.
- User override path 보존 — `config.toml [pdf.ocr] request_timeout_secs = N` 로 늘릴 수 있음 (release notes 에 명문).

**Amends**: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §1000 / §1628 OQ-1 (parent spec frozen — text 변경 없음, inline HTML 주석 cross-link 1 줄만 추가). 본 entry 가 live source of truth.
```

날짜 헤더의 위치는 기존 entries 의 시간순 (가장 최근이 file 위쪽 또는 아래쪽 — 본 file 의 `## 2026-05-01 —` 이후로 이어지는 자리). executor 가 file head + 가장 최근 dated subsection 의 위치 보고 정확한 anchor 삽입.

**(b) parent spec inline 주석** — frozen text 보존:

`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 의 PDF OCR config block (또는 OQ-1 timeout 언급 위치, brief 의 §1000/§1628 reference) 에 다음 1 줄 HTML 주석 inline 추가:

```markdown
<!-- HOTFIX 2026-05-27: pdf.ocr.request_timeout_secs default 60s (Bug #11). See tasks/HOTFIXES.md 2026-05-27 entry. -->
```

위치: executor 가 `grep -n "request_timeout_secs\|600\|pdf.*timeout\|OQ-1" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 로 가장 가까운 anchor 식별 후 그 line 의 직후 또는 같은 paragraph 끝에 inline 으로 삽입. **frozen text 의 prose 자체는 변경 0** — HTML 주석 (`<!-- ... -->`) 은 markdown render 시 invisible.

#### §2.6.3 New tests

없음 (docs-only commit).

#### §2.6.4 Per-step acceptance

```bash
# parent spec 의 prose diff 가 주석 1 줄 외에는 0 인지 확인:
git diff docs/superpowers/specs/2026-04-27-kebab-final-form-design.md | grep -E "^[+-][^<]" | head -20
# 위 결과는 모두 "+<!-- HOTFIX 2026-05-27 ... -->" 만 보여야 함.

# HOTFIXES entry markdown render 검증 (link sanity):
python3 -c "import pathlib; t = pathlib.Path('tasks/HOTFIXES.md').read_text(); assert '2026-05-27 — PDF OCR' in t and 'Bug #11' in t"

# (optional) markdownlint 가 repo 에 wired 되어 있으면 양쪽 file 에 대해 실행.
```

#### §2.6.5 Commit

```bash
git add tasks/HOTFIXES.md docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
git commit -m "$(cat <<'EOF'
docs(spec): HOTFIXES entry + parent spec cross-link for Bug #11 timeout deviation

Bug #11 (이전 commit `fix(config): pdf.ocr.request_timeout_secs default 600 → 60`)
의 frozen-spec deviation handoff.

- tasks/HOTFIXES.md: 2026-05-27 dated subsection — Discovered / Symptom / Root cause /
  Fix / Amends 5-field 포맷 (기존 entries 와 일치).
- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md: PDF OCR config block
  (§1000 / §1628 OQ-1 부근) 에 inline HTML 주석 1 줄 cross-link. prose 변경 0 —
  parent spec frozen contract 보존, HTML 주석은 markdown render 시 invisible.

HOTFIXES entry 가 live source of truth (CLAUDE.md "Spec contract" 규칙).

EOF
)"
```

---

### Step 7 — Final sanity (no commit)

#### §2.7.1 Workspace-wide check

```bash
# 전체 빌드 + clippy 한 번에:
cargo build --workspace --release -j 4
cargo clippy --workspace --all-targets -- -D warnings

# 전체 test (-j 1 — 18 integration-test binary 의 link OOM 방지):
cargo test --workspace --no-fail-fast -j 1
```

기준: 기존 1350 test + 본 round 새 +7 test = **1357+ test, 모두 green**. fail 시 step-별로 어디서 regression 인지 isolate.

#### §2.7.2 (Optional) Dogfood retest

지금 fresh release binary 가 이미 bugfix2 round 까지 반영 (`/build/out/cargo-target/target/release/kebab`). bugfix3 commit 후 release rebuild + 5 bug 별 수동 smoke:

```bash
# Bug #9: capabilities 둘 다 true.
kebab schema --json | jq '.capabilities | {streaming_ask, single_file_ingest}'

# Bug #10: nonexistent --config → exit≠0 + error.v1 code=config_not_found.
kebab search rust --config /tmp/nope.toml --json; echo "exit=$?"

# Bug #11: defaults 의 timeout 60.
kebab config dump 2>/dev/null | grep request_timeout_secs   # (또는 init template 확인)

# Bug #13: mixed corpus 에서 active_parsers/chunkers 둘 다 populate.
kebab schema --json | jq '.models | {active_parsers, active_chunkers}'

# Bug #14: empty query 양쪽 모두 invalid_input.
kebab search "" --json; echo "exit=$?"
kebab ask "" --json; echo "exit=$?"
```

dogfood 는 optional — workspace test green + clippy clean 가 commit 의 충분 조건. dogfood 결과는 final round 의 review 단계에서 캡쳐.

#### §2.7.3 Branch state

```bash
git log --oneline -7
# (예상)
# <h6> docs(spec): HOTFIXES entry + parent spec cross-link for Bug #11 timeout deviation
# <h5> fix(cli): empty query emits error.v1 invalid_input for search + ask (Bug #14)
# <h4> feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)
# <h3> fix(config): pdf.ocr.request_timeout_secs default 600 → 60 per dogfood evidence (Bug #11)
# <h2> fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)
# <h1> fix(app): flip streaming_ask + single_file_ingest capabilities to actual surface (Bug #9)
# f763049 test(cli): assert 'code' in search --help output (Bug #7 regression pin)
```

6 commit (Step 6 = 1 doc commit). PR 는 `feat/pdf-scanned-ocr` branch 그대로 force-update (base=main, spec §0 의 "PR #189 force-update" 참조).

---

## §3 Verifier checklist

cumulative — Step 7 까지 진행 후 verifier 가 점검.

| # | Criterion | Command | Expected | Spec AC |
|----|----------|---------|----------|---------|
| V-1 | capabilities flag 둘 다 true (`streaming_ask` + `single_file_ingest`) | `cargo test -p kebab-app capabilities_streaming_ask_matches_cli_surface capabilities_single_file_ingest_matches_cli_surface -j 4` | green | AC-1 |
| V-2 | absolute missing `--config` → exit≠0 + error.v1 code=config_not_found | `cargo test -p kebab-cli invalid_config_path_emits_error_v1_with_nonzero_exit -j 4` | green | AC-2 |
| V-3 | relative missing `--config` → exit≠0 + error.v1 code=config_not_found | `cargo test -p kebab-cli invalid_relative_config_path_emits_config_not_found -j 4` | green | AC-7 |
| V-4 | OCR timeout default 60s | `cargo test -p kebab-config pdf_ocr_request_timeout_default_is_60s -j 4` | green | AC-3 |
| V-5 | active_parsers/chunkers populate on mixed corpus + 빈 corpus 빈 array | `cargo test -p kebab-app schema_emits_active_parsers_and_chunkers_array_after_mixed_ingest schema_models_active_arrays_empty_on_empty_corpus -j 4` | green | AC-4 |
| V-6 | empty query (search "" + "  ") → invalid_input | `cargo test -p kebab-cli search_empty_query_emits_invalid_input -j 4` | green | AC-5 |
| V-7 | empty query (ask "") → invalid_input | `cargo test -p kebab-cli ask_empty_query_emits_invalid_input -j 4` | green | AC-6 |
| V-8 | workspace test 전수 green | `cargo test --workspace --no-fail-fast -j 1` | exit=0, 1357+ test green | AC-8 |
| V-9 | clippy clean | `cargo clippy --workspace --all-targets -- -D warnings` | exit=0, warn 0 | AC-7 spec layer |
| V-10 | wire schema additive minor valid (parse + required 유지) | `python3 -c "import json; s=json.load(open('docs/wire-schema/v1/schema.schema.json')); assert 'active_parsers' in s['properties']['models']['properties']; assert 'active_parsers' not in s['properties']['models']['required']"` | exit=0 | AC-9 |
| V-11 | SKILL.md 동기 갱신 (active_* 언급) | `grep -q "active_parsers\|active_chunkers" integrations/claude-code/kebab/SKILL.md` | exit=0 | AC-10 |
| V-12 | HOTFIXES entry + parent spec cross-link 존재 | `grep -q "2026-05-27 — PDF OCR" tasks/HOTFIXES.md && grep -q "HOTFIX 2026-05-27: pdf.ocr" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` | exit=0 | AC-8 spec layer |
| V-13 | parent spec prose 변경 0 (HTML 주석만) | `git diff main -- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md \| grep -E "^[+-][^<]" \| wc -l` | 0 | constraint §7.2 |
| V-14 | (optional manual) `kebab ask --stream` regression 없음 | `kebab ask --stream "what is rust" 2>&1 \| head -3` | answer_event.v1 ndjson | AC-10 manual |

V-1 ~ V-12 자동, V-13 자동 (diff line count 0), V-14 manual / optional.

---

## §4 Risks resolution (spec §6 의 plan-level)

| Risk | Resolution | Step | Verification |
|------|-----------|------|--------------|
| R-1 — config layer 순서 (kebab-config error vs kebab-app classify) | spec §3.2 의 (a) 선택: `kebab-config` 자체 error type + `kebab-app` downcast. 기존 `ConfigInvalid` pattern 그대로 mirror (re-export 4 file: error_signal.rs, lib.rs, error_wire.rs, classify). | Step 2 | V-2 + V-3 + 기존 error_wire test (config_invalid_classifies_to_config_invalid_code) 보존. |
| R-2 — config relative path (cwd-relative) | `std::path::Path::exists()` 는 cwd-relative — 별도 작업 0. integration test 가 `tempfile::tempdir() + current_dir(...)` 로 absolute / relative 양쪽 cover. | Step 2 | V-3. |
| R-3 — active_* cache invalidation | `collect_models` 가 매 schema 호출마다 store query 직접 — cache 없음. R-3 N/A. | Step 4 | V-5 (빈 corpus + mixed 둘 다). 향후 caching 추가 시 corpus_revision invalidation 명문. |
| R-4 — corpus shrink 시 stale | 위와 동일 (every-call 재계산). | Step 4 | V-5. |
| R-5 — 60s 도 dense/고해상도 page timeout 가능 | mitigation: config.toml `[pdf.ocr] request_timeout_secs = N` override path 유지. 새 release notes 명문 (v0.20.1 minor bump 시). | Step 3 + Step 6 | V-4 (default value test). release notes 는 본 plan 범위 외 — gitea-release 시 cover. |

추가 OQ — Step 2 의 ConfigNotFound 가 `Config::from_file` 의 read_failed (file 존재하나 IO error) 와 구분되는가? **결정**: 본 fix 는 `!p.exists()` path 만 처리. file 은 존재하나 permission denied 등 IO error 는 기존 `ConfigInvalid::read_failed` (line 729-733) path 그대로 — 두 error 가 명확히 disjoint.

---

## §5 Open questions for executor

1. **Step 2 (b)** — `ConfigNotFound` 추가 시 `crates/kebab-app/src/error_signal.rs` 의 정확한 export shape: 기존 `pub use kebab_config::ConfigInvalid;` 가 그 file 에 있는지 vs `lib.rs` 직접 인지 grep 으로 확인. 기존 pattern 그대로 mirror.
2. **Step 3** — 기존 unit test 중 `request_timeout_secs.*600` 가 있는지 `grep -rn "request_timeout_secs.*600\|600.*request_timeout_secs" crates/kebab-config/` 로 사전 확인. 발견 시 같은 commit 안에서 expect 값을 60 으로 갱신 (별도 commit 금지 — atomic).
3. **Step 4 (a)** — `SqliteStore::conn()` 의 정확한 method 이름이 다른 file 에 따라 (`get_conn`, `connection`, `with_conn` 등) 다를 수 있음. 기존 `code_lang_breakdown` / `repo_breakdown` impl 옆에 같은 pattern 으로 추가 — connection 획득 line 그대로 복붙.
4. **Step 4 (e)** — `integrations/claude-code/kebab/SKILL.md` 의 schema description 갱신 범위: brief §0.1 의 "6-axis → 8-axis 또는 '+ active arrays'" 중 후자 채택 (6-axis 라는 표현이 다른 곳에 인용될 수 있어 number 를 늘리는 대신 "+ array" 추가 형식 — backward compat 표현).
5. **Step 6 (b)** — parent spec inline 주석의 정확한 anchor: brief 의 "§1000 / §1628 OQ-1" 은 spec body 안 section number 가 아닌 line number 일 가능성. executor 가 `wc -l docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` + `grep -n "request_timeout_secs\|OQ-1\|600" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 결과로 가장 가까운 anchor 식별 후 inline insertion. **prose 변경 0** 이 핵심 invariant.
6. **Step 5 — `--bulk` precedence**: 기존 line 730 (`Some(bulk)` 분기) 이 query empty check 보다 먼저인지 확인. 본 plan 은 그렇다고 가정 (line 818 에서 cfg load 가 일어나기 전에 bulk return) — false 면 bulk path 가 empty check 의 영향을 받음.

---

## §6 References

- spec: `docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix3-spec.md` (410 line, ACCEPT 11/11)
- brief: `.omc/reviews/2026-05-27-v0.20-bugfix3-plan-drafter-brief.md`
- prior critic rounds:
  - `.omc/reviews/2026-05-27-v0.20-bugfix3-spec-closure-result.md`
  - `.omc/reviews/2026-05-27-v0.20-bugfix3-spec-closure-r2-result.md`
- source dogfood report: `.omc/reviews/2026-05-27-v0.20-final-dogfood-report.md`
- code anchors:
  - `crates/kebab-app/src/schema.rs:137-151` (capabilities_snapshot)
  - `crates/kebab-app/src/schema.rs:192-207` (collect_models)
  - `crates/kebab-config/src/lib.rs:19-22` (ConfigInvalid pattern model)
  - `crates/kebab-config/src/lib.rs:477` (default_pdf_ocr_request_timeout_secs)
  - `crates/kebab-config/src/lib.rs:688-722` (Config::load)
  - `crates/kebab-app/src/error_wire.rs:49-104` (classify)
  - `crates/kebab-cli/src/main.rs:206` (Ask query: String)
  - `crates/kebab-cli/src/main.rs:718` (Cmd::Search)
  - `crates/kebab-cli/src/main.rs:818-826` (search arm Config::load + query_text)
  - `crates/kebab-cli/src/main.rs:977-990` (Cmd::Ask)
  - `docs/wire-schema/v1/schema.schema.json:30-44` (Models object)
  - `integrations/claude-code/kebab/SKILL.md:155` (schema description)
  - `tasks/HOTFIXES.md` (dated entries pattern)

---

## §7 Constraints (spec §7 mirror)

1. **branch 변경 0** — 모든 commit 이 `feat/pdf-scanned-ocr` 에 올라감. base=main 의 force-update 만 (Step 7 후 PR push).
2. **spec ACCEPT (frozen contract) 변경 0** — `docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix3-spec.md` 본 plan 안에서 read-only.
3. **regression 0** — 기존 workspace test (~1350) 전수 green + 본 round 새 +7 test 추가 (총 1357+).
4. **wire schema = additive minor** — Bug #13 의 `active_parsers` / `active_chunkers` 는 optional, JSON schema `required` 미포함, `#[serde(default)]`. v0.20.1 minor bump 로 충분.
5. **parent spec text 변경 = inline HTML 주석 1 줄만** — frozen prose 보존, HOTFIXES.md 가 live source of truth.
6. **subagent skip** — direct in-session 작성, nested worker spawn 금지 (worker protocol).
7. **commit message style**: 기존 commit log (`f763049 test(cli): ...`, `8cf73d1 docs(cli): ...`, `a58ee10 fix(parse-pdf): ...`) 의 `kind(scope): subject (Bug #N)` pattern 그대로. body 는 Why + What — 본 plan 의 commit block 그대로 사용.
8. **estimated time**: 60 min — Step 1 (5min) + Step 2 (15min) + Step 3 (5min) + Step 4 (20min) + Step 5 (10min) + Step 6 (5min) + Step 7 sanity. spec 의 30-45 min 보다 보수적.

---

_End of plan._
