From 4728a87957dafc0fdd004d0ac7854114b675a56a Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sat, 9 May 2026 00:40:46 +0900
Subject: [PATCH] plan(fb-32): stale doc indicator implementation plan
15 tasks covering domain (kebab-core SearchHit + AnswerCitation),
config (SearchCfg.stale_threshold_days), retrievers (lexical + vector
JOIN documents.updated_at), App facade (staleness module + cache
re-stamp), wire schema, CLI plain [stale] tag, TUI [STALE] Warning
badge, snapshot fan-out, docs.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
...2026-05-09-p9-fb-32-stale-doc-indicator.md | 1669 +++++++++++++++++
1 file changed, 1669 insertions(+)
create mode 100644 docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md
diff --git a/docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md b/docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md
new file mode 100644
index 0000000..d15d179
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md
@@ -0,0 +1,1669 @@
+# p9-fb-32 — Stale Doc Indicator Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Surface "indexed-at timestamp + stale boolean" on every search hit and RAG citation so users / agents see when a doc was last re-processed and whether it crossed a configurable freshness threshold.
+
+**Architecture:** Reuse `documents.updated_at` (already RFC3339, already excluded from fb-23 skip path → natural source-of-truth for "last re-processed"). Two retrievers (lexical, vector) JOIN documents to extract it. App facade computes `stale = now - indexed_at > threshold * 86400s`. `now` is threaded as an explicit `OffsetDateTime` parameter (no Clock trait — codebase has no precedent and the explicit-arg pattern is enough for test determinism). Wire serialization is automatic via `serde_json::to_value` on the domain types. CLI plain output gains `[stale]` tag; TUI gains `[STALE]` Warning-styled badge; agent JSON gains `indexed_at` + `stale` fields.
+
+**Tech Stack:** Rust 2024, time crate (RFC3339), serde, rusqlite, refinery (no new migration), insta (snapshot redaction), JSON Schema (search_hit/citation v1).
+
+**Spec:** `docs/superpowers/specs/2026-05-08-p9-fb-32-stale-doc-indicator-design.md`
+
+---
+
+## File Structure
+
+| File | Responsibility | Action |
+|------|----------------|--------|
+| `crates/kebab-core/src/search.rs` | Domain `SearchHit` — add `indexed_at` + `stale` fields | modify |
+| `crates/kebab-core/src/answer.rs` | Domain `AnswerCitation` — add `indexed_at` + `stale` fields | modify |
+| `crates/kebab-config/src/lib.rs` | `SearchCfg.stale_threshold_days` field + default + env override + load-time validation | modify |
+| `crates/kebab-search/src/lexical.rs` | JOIN `documents.updated_at`, parse RFC3339, populate `SearchHit.indexed_at` | modify |
+| `crates/kebab-search/src/vector.rs` | Same JOIN extension in `hydrate_chunks` + populate | modify |
+| `crates/kebab-app/src/staleness.rs` | New module — `compute_stale(indexed_at, now, threshold_days) -> bool` + `mark_stale_in_place(&mut [SearchHit], now, threshold_days)` | create |
+| `crates/kebab-app/src/app.rs` | Call `mark_stale_in_place` after `search_uncached` AND after cache hits in `App::search`. Compute `now` once per call. | modify |
+| `crates/kebab-app/src/lib.rs` (RAG path) | Compute `stale` for `AnswerCitation` items returned by `App::ask` | modify |
+| `docs/wire-schema/v1/search_hit.schema.json` | Add `indexed_at` + `stale` to required + properties | modify |
+| `docs/wire-schema/v1/citation.schema.json` | Add `indexed_at` + `stale` to required + properties | modify |
+| `crates/kebab-cli/src/render.rs` (or equivalent plain renderer) | `[stale]` tag on hit / citation lines (TTY color when capable) | modify |
+| `crates/kebab-tui/src/` | `[STALE]` Span via `Theme::style(Role::Warning)` | modify |
+| `crates/kebab-app/tests/staleness.rs` | Unit tests for `compute_stale` boundary + threshold=0 | create |
+| `crates/kebab-app/tests/search_stale_integration.rs` | Integration: ingest doc → fast-forward `now` → verify `stale=true` | create |
+| `crates/kebab-config/src/lib.rs` (tests) | Unit: default 30, env override 7, negative → error | modify |
+| `crates/kebab-cli/tests/wire_search_stale.rs` | Wire JSON contains `indexed_at` + `stale` on hits | create |
+| `crates/kebab-cli/tests/wire_ask_stale.rs` | Wire JSON contains `indexed_at` + `stale` on `answer.citations[]` | create |
+| `crates/kebab-tui/tests/snapshots/*` | Insta redaction filter for `indexed_at` (pattern `[indexed_at]`) | modify (existing) |
+| `README.md` | Configuration section — `stale_threshold_days` line | modify |
+| `docs/SMOKE.md` | Config example block + walkthrough paragraph | modify |
+| `tasks/p9/p9-fb-32-stale-doc-indicator.md` | Status flip + design/plan links | modify |
+| `tasks/INDEX.md` | fb-32 row → ✅ + 0.4.0 trigger note | modify |
+| `integrations/claude-code/kebab/SKILL.md` | Parsing tip line about `indexed_at` / `stale` | modify |
+
+---
+
+## Pre-flight
+
+- [ ] **Step 0.1: Branch off main**
+
+```bash
+git checkout main
+git pull
+git checkout -b feat/fb-32-stale-doc-indicator
+```
+
+- [ ] **Step 0.2: Confirm spec branch is reachable**
+
+```bash
+git log --oneline spec/fb-32-stale-doc-indicator -1
+```
+
+Expected: shows `401a47f spec(fb-32): stale doc indicator — design`. Spec lives on its own branch; the implementation branch does NOT need to merge spec since the spec file is on `main` once the spec PR lands. If spec PR not yet merged, `git merge spec/fb-32-stale-doc-indicator` first.
+
+---
+
+## Task 1: Domain — `SearchHit` gains `indexed_at` + `stale`
+
+**Files:**
+- Modify: `crates/kebab-core/src/search.rs`
+
+- [ ] **Step 1.1: Write the failing test**
+
+Append to `crates/kebab-core/src/search.rs` `#[cfg(test)]` block (create one if absent):
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use time::OffsetDateTime;
+ use time::macros::datetime;
+
+ #[test]
+ fn search_hit_serializes_indexed_at_and_stale() {
+ let hit = SearchHit {
+ rank: 1,
+ chunk_id: ChunkId("c".to_string()),
+ doc_id: DocumentId("d".to_string()),
+ doc_path: WorkspacePath::new("a/b.md".to_string()).unwrap(),
+ heading_path: vec!["H".to_string()],
+ section_label: None,
+ snippet: "s".to_string(),
+ citation: Citation::Line {
+ path: WorkspacePath::new("a/b.md".to_string()).unwrap(),
+ start: 1,
+ end: 1,
+ section: None,
+ },
+ retrieval: RetrievalDetail {
+ method: SearchMode::Lexical,
+ fusion_score: 0.5,
+ lexical_score: Some(0.5),
+ vector_score: None,
+ lexical_rank: Some(1),
+ vector_rank: None,
+ },
+ index_version: IndexVersion("v1".to_string()),
+ embedding_model: None,
+ chunker_version: ChunkerVersion("c1".to_string()),
+ indexed_at: datetime!(2026-05-09 12:00:00 UTC),
+ stale: true,
+ };
+ let v = serde_json::to_value(&hit).unwrap();
+ assert_eq!(v["indexed_at"], "2026-05-09T12:00:00Z");
+ assert_eq!(v["stale"], true);
+ }
+}
+```
+
+- [ ] **Step 1.2: Run test — verify it fails**
+
+```bash
+cargo test -p kebab-core search_hit_serializes_indexed_at_and_stale
+```
+
+Expected: FAIL — "missing field `indexed_at`" or "no field `indexed_at` on type `SearchHit`".
+
+- [ ] **Step 1.3: Implement — add fields to `SearchHit`**
+
+Modify `crates/kebab-core/src/search.rs` `SearchHit` struct (the existing `pub struct SearchHit { ... }` block):
+
+```rust
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct SearchHit {
+ pub rank: u32,
+ pub chunk_id: ChunkId,
+ pub doc_id: DocumentId,
+ pub doc_path: WorkspacePath,
+ pub heading_path: Vec,
+ pub section_label: Option,
+ pub snippet: String,
+ pub citation: Citation,
+ pub retrieval: RetrievalDetail,
+ pub index_version: IndexVersion,
+ pub embedding_model: Option,
+ pub chunker_version: ChunkerVersion,
+ /// p9-fb-32: source doc's `documents.updated_at` (last actual re-process).
+ /// fb-23 incremental ingest skip path leaves this unchanged.
+ #[serde(with = "time::serde::rfc3339")]
+ pub indexed_at: OffsetDateTime,
+ /// p9-fb-32: server-computed `now - indexed_at > threshold` per
+ /// `config.search.stale_threshold_days`. `false` when threshold = 0.
+ pub stale: bool,
+}
+```
+
+- [ ] **Step 1.4: Run test — verify it passes**
+
+```bash
+cargo test -p kebab-core search_hit_serializes_indexed_at_and_stale
+```
+
+Expected: PASS. Other tests in the workspace will now fail to compile (every site building `SearchHit` is missing the two fields). That's expected — Tasks 4 / 5 / 7 plug them in. Do **not** add `..Default::default()` workarounds; let the compiler errors guide the next tasks.
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add crates/kebab-core/src/search.rs
+git commit -m "$(cat <<'EOF'
+feat(core): SearchHit gains indexed_at + stale (fb-32)
+
+Domain field additions for p9-fb-32. Wire serialization is
+automatic via serde rfc3339. Other crates fail to compile until
+they populate the new fields — fixed in subsequent tasks.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: Domain — `AnswerCitation` gains `indexed_at` + `stale`
+
+**Files:**
+- Modify: `crates/kebab-core/src/answer.rs`
+
+- [ ] **Step 2.1: Write the failing test**
+
+Append to `crates/kebab-core/src/answer.rs` (create `#[cfg(test)] mod tests` if absent):
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::asset::WorkspacePath;
+ use crate::citation::Citation;
+ use time::macros::datetime;
+
+ #[test]
+ fn answer_citation_serializes_indexed_at_and_stale() {
+ let ac = AnswerCitation {
+ marker: Some("[1]".to_string()),
+ citation: Citation::Line {
+ path: WorkspacePath::new("a.md".to_string()).unwrap(),
+ start: 1,
+ end: 1,
+ section: None,
+ },
+ indexed_at: datetime!(2026-05-09 12:00:00 UTC),
+ stale: false,
+ };
+ let v = serde_json::to_value(&ac).unwrap();
+ assert_eq!(v["indexed_at"], "2026-05-09T12:00:00Z");
+ assert_eq!(v["stale"], false);
+ }
+}
+```
+
+- [ ] **Step 2.2: Run test — verify it fails**
+
+```bash
+cargo test -p kebab-core answer_citation_serializes_indexed_at_and_stale
+```
+
+Expected: FAIL — missing fields on `AnswerCitation`.
+
+- [ ] **Step 2.3: Implement — add fields**
+
+Modify `crates/kebab-core/src/answer.rs` `AnswerCitation`:
+
+```rust
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct AnswerCitation {
+ pub marker: Option,
+ pub citation: Citation,
+ /// p9-fb-32: cited doc's `documents.updated_at`.
+ #[serde(with = "time::serde::rfc3339")]
+ pub indexed_at: OffsetDateTime,
+ /// p9-fb-32: server-computed staleness flag per config threshold.
+ pub stale: bool,
+}
+```
+
+`OffsetDateTime` is already imported at the top of the file.
+
+`Turn.citations` is also `Vec` — automatically picks up the new fields.
+
+- [ ] **Step 2.4: Run test**
+
+```bash
+cargo test -p kebab-core answer_citation_serializes_indexed_at_and_stale
+```
+
+Expected: PASS.
+
+- [ ] **Step 2.5: Commit**
+
+```bash
+git add crates/kebab-core/src/answer.rs
+git commit -m "$(cat <<'EOF'
+feat(core): AnswerCitation gains indexed_at + stale (fb-32)
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 3: Config — `SearchCfg.stale_threshold_days`
+
+**Files:**
+- Modify: `crates/kebab-config/src/lib.rs`
+
+- [ ] **Step 3.1: Write the failing test (default + env override)**
+
+Find the existing tests module in `crates/kebab-config/src/lib.rs` (search for `mod tests` or `#[test]`). Append:
+
+```rust
+#[test]
+fn default_stale_threshold_is_30() {
+ let c = Config::defaults();
+ assert_eq!(c.search.stale_threshold_days, 30);
+}
+
+#[test]
+fn env_override_stale_threshold() {
+ let mut c = Config::defaults();
+ let env: HashMap = [
+ ("KEBAB_SEARCH_STALE_THRESHOLD_DAYS".to_string(), "7".to_string()),
+ ]
+ .into_iter()
+ .collect();
+ c.apply_env(&env);
+ assert_eq!(c.search.stale_threshold_days, 7);
+}
+
+#[test]
+fn negative_stale_threshold_rejected_at_validation() {
+ let mut c = Config::defaults();
+ // u32 cannot hold a negative — represent the failure path through
+ // `apply_env` parse-failure: malformed values are silently ignored
+ // (existing pattern, see KEBAB_SEARCH_DEFAULT_K). For TOML-level
+ // negative rejection we rely on serde's u32 type; assert that the
+ // env path leaves the default in place when given garbage.
+ let env: HashMap = [
+ ("KEBAB_SEARCH_STALE_THRESHOLD_DAYS".to_string(), "-5".to_string()),
+ ]
+ .into_iter()
+ .collect();
+ c.apply_env(&env);
+ assert_eq!(c.search.stale_threshold_days, 30, "garbage env value must not corrupt the default");
+}
+```
+
+(`HashMap` import — verify it's in scope in the existing tests module; if not, add `use std::collections::HashMap;` to the tests module.)
+
+- [ ] **Step 3.2: Run tests — verify they fail**
+
+```bash
+cargo test -p kebab-config default_stale_threshold_is_30 env_override_stale_threshold negative_stale_threshold_rejected_at_validation
+```
+
+Expected: FAIL — no field `stale_threshold_days` on `SearchCfg`.
+
+- [ ] **Step 3.3: Implement — add field, default, env mapping**
+
+Modify `crates/kebab-config/src/lib.rs` `SearchCfg`:
+
+```rust
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct SearchCfg {
+ pub default_k: usize,
+ pub hybrid_fusion: String,
+ pub rrf_k: u32,
+ pub snippet_chars: usize,
+ /// p9-fb-19: in-memory LRU cache capacity for `App::search`.
+ /// One entry ≈ 5 KB → default 256 caps memory at ~1.3 MB. Set
+ /// to `0` to disable the cache entirely. Stale entries
+ /// (corpus_revision mismatch) are evicted on next access.
+ #[serde(default = "default_cache_capacity")]
+ pub cache_capacity: usize,
+ /// p9-fb-32: hits and citations whose source doc was last
+ /// re-processed more than this many days ago are marked
+ /// `stale: true` in wire / TUI / CLI surfaces. `0` disables.
+ #[serde(default = "default_stale_threshold_days")]
+ pub stale_threshold_days: u32,
+}
+
+fn default_stale_threshold_days() -> u32 {
+ 30
+}
+```
+
+Also update the `Config::defaults()` literal — add `stale_threshold_days: 30,` to the `SearchCfg { ... }` block (around line 314-320).
+
+Add the env mapping. Locate the existing `// search` comment near line 563 in `apply_env`. Append a new arm after `KEBAB_SEARCH_SNIPPET_CHARS`:
+
+```rust
+"KEBAB_SEARCH_STALE_THRESHOLD_DAYS" => {
+ if let Ok(n) = v.parse::() {
+ self.search.stale_threshold_days = n;
+ }
+}
+```
+
+(Garbage values fail `parse::()` and silently leave the default in place — matches the existing pattern documented at line 471-473.)
+
+- [ ] **Step 3.4: Run tests — verify they pass**
+
+```bash
+cargo test -p kebab-config default_stale_threshold_is_30 env_override_stale_threshold negative_stale_threshold_rejected_at_validation
+```
+
+Expected: PASS.
+
+- [ ] **Step 3.5: Update the test fixture TOML literal**
+
+`crates/kebab-config/src/lib.rs` line 943-946 has the `[search]` section embedded in a fixture string. Append:
+
+```diff
+ default_k = 10
+ hybrid_fusion = "rrf"
+ rrf_k = 60
+ snippet_chars = 220
++stale_threshold_days = 30
+```
+
+(Search the file for `default_k = 10` to find the exact spot. Verify the surrounding test still passes.)
+
+- [ ] **Step 3.6: Run full config crate tests**
+
+```bash
+cargo test -p kebab-config
+```
+
+Expected: PASS — all tests including pre-existing ones.
+
+- [ ] **Step 3.7: Commit**
+
+```bash
+git add crates/kebab-config/src/lib.rs
+git commit -m "$(cat <<'EOF'
+feat(config): search.stale_threshold_days (fb-32)
+
+default 30 days. env override KEBAB_SEARCH_STALE_THRESHOLD_DAYS.
+Malformed env values are silently ignored, matching the existing
+apply_env pattern.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Lexical retriever — JOIN `documents.updated_at`
+
+**Files:**
+- Modify: `crates/kebab-search/src/lexical.rs`
+
+- [ ] **Step 4.1: Write the failing test**
+
+Append to `crates/kebab-search/tests/lexical.rs` (the integration test file):
+
+```rust
+#[test]
+fn search_hit_carries_indexed_at_from_documents_updated_at() {
+ let env = TestEnv::new(); // adapt to existing test scaffold in this file
+ env.ingest_doc("a.md", "# T\n\nbody about apples\n");
+ let hits = env.lexical_search("apples", 5);
+ let hit = hits.first().expect("at least one hit");
+ // updated_at is RFC3339; OffsetDateTime equality on a freshly-ingested
+ // doc should be within the last 60 seconds of `now_utc()`.
+ let now = time::OffsetDateTime::now_utc();
+ let delta = (now - hit.indexed_at).whole_seconds().abs();
+ assert!(delta < 60, "indexed_at within ±60s of now, got {delta}s");
+}
+```
+
+If `tests/lexical.rs` does not have a `TestEnv` helper, examine the existing tests in that file and copy the pattern they use (likely a builder that creates a `LexicalRetriever` against a temp SQLite). The exact scaffold is dictated by what's there — adapt accordingly. Do not invent a new framework.
+
+- [ ] **Step 4.2: Run test — verify it fails to compile**
+
+```bash
+cargo test -p kebab-search --test lexical search_hit_carries_indexed_at
+```
+
+Expected: FAIL — RawRow has no `updated_at`, hit construction missing `indexed_at` field.
+
+- [ ] **Step 4.3: Implement — extend RawRow + SQL**
+
+Modify `crates/kebab-search/src/lexical.rs`:
+
+In the `RawRow` struct (line ~237), add:
+
+```rust
+struct RawRow {
+ chunk_id: String,
+ doc_id: String,
+ bm25_raw: f64,
+ snippet: String,
+ heading_path_json: String,
+ section_label: Option,
+ source_spans_json: String,
+ chunker_version: String,
+ workspace_path: String,
+ /// p9-fb-32: documents.updated_at (RFC3339).
+ updated_at: String,
+}
+```
+
+In `run_query` (line ~251), extend the SELECT clause:
+
+```rust
+let mut sql = String::from(
+ "SELECT \
+ f.chunk_id, f.doc_id, \
+ bm25(chunks_fts) AS score, \
+ snippet(chunks_fts, 3, '', '', '…', ?) AS snippet, \
+ c.heading_path_json, c.section_label, c.source_spans_json, \
+ c.chunker_version, \
+ d.workspace_path, \
+ d.updated_at \
+ FROM chunks_fts f \
+ JOIN chunks c ON c.chunk_id = f.chunk_id \
+ JOIN documents d ON d.doc_id = f.doc_id",
+);
+```
+
+In `row_from_sql` (line ~341), pull index 9:
+
+```rust
+fn row_from_sql(row: &Row<'_>) -> rusqlite::Result {
+ Ok(RawRow {
+ chunk_id: row.get(0)?,
+ doc_id: row.get(1)?,
+ bm25_raw: row.get(2)?,
+ snippet: row.get(3)?,
+ heading_path_json: row.get(4)?,
+ section_label: row.get(5)?,
+ source_spans_json: row.get(6)?,
+ chunker_version: row.get(7)?,
+ workspace_path: row.get(8)?,
+ updated_at: row.get(9)?,
+ })
+}
+```
+
+In `build_hit` (line ~357), parse RFC3339 + populate:
+
+```rust
+let indexed_at = time::OffsetDateTime::parse(
+ &raw.updated_at,
+ &time::format_description::well_known::Rfc3339,
+)
+.context("kb-search lexical: parse documents.updated_at as RFC3339")?;
+
+Ok(SearchHit {
+ rank,
+ chunk_id: ChunkId(raw.chunk_id),
+ // ... existing fields ...
+ chunker_version: ChunkerVersion(raw.chunker_version),
+ indexed_at,
+ stale: false, // placeholder — App layer overwrites
+})
+```
+
+(`stale: false` is the placeholder. Task 6 owns the post-process pass that sets the real value.)
+
+- [ ] **Step 4.4: Run test — verify it passes**
+
+```bash
+cargo test -p kebab-search --test lexical
+```
+
+Expected: PASS for new test. Existing lexical tests should also still pass.
+
+- [ ] **Step 4.5: Commit**
+
+```bash
+git add crates/kebab-search/src/lexical.rs crates/kebab-search/tests/lexical.rs
+git commit -m "$(cat <<'EOF'
+feat(search/lexical): populate SearchHit.indexed_at (fb-32)
+
+JOIN documents.updated_at. stale defaults to false; App facade
+post-processes against config threshold.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 5: Vector retriever — extend `hydrate_chunks`
+
+**Files:**
+- Modify: `crates/kebab-search/src/vector.rs`
+
+- [ ] **Step 5.1: Write the failing test**
+
+Append to `crates/kebab-search/tests/hybrid.rs` (or `crates/kebab-search/tests/vector.rs` if separate — check what's there):
+
+```rust
+#[test]
+fn vector_hit_carries_indexed_at() {
+ let env = HybridTestEnv::new(); // adapt to existing scaffold
+ env.ingest_doc("a.md", "# T\n\napples are fruit\n");
+ let hits = env.vector_search("apples", 5);
+ let hit = hits.first().expect("at least one vector hit");
+ let now = time::OffsetDateTime::now_utc();
+ let delta = (now - hit.indexed_at).whole_seconds().abs();
+ assert!(delta < 60, "indexed_at within ±60s of now, got {delta}s");
+}
+```
+
+- [ ] **Step 5.2: Run test — verify it fails**
+
+```bash
+cargo test -p kebab-search vector_hit_carries_indexed_at
+```
+
+Expected: FAIL — `ChunkMeta` has no `updated_at`, missing `indexed_at` on built hit.
+
+- [ ] **Step 5.3: Implement — extend ChunkMeta + SQL + build path**
+
+Modify `crates/kebab-search/src/vector.rs`:
+
+`ChunkMeta` (line ~192):
+
+```rust
+struct ChunkMeta {
+ text: String,
+ heading_path_json: String,
+ section_label: Option,
+ source_spans_json: String,
+ chunker_version: String,
+ doc_id: String,
+ workspace_path: String,
+ /// p9-fb-32: documents.updated_at (RFC3339).
+ updated_at: String,
+}
+```
+
+`hydrate_chunks` SELECT (line ~221):
+
+```rust
+let sql = format!(
+ "SELECT \
+ c.chunk_id, c.text, c.heading_path_json, c.section_label, \
+ c.source_spans_json, c.chunker_version, \
+ c.doc_id, d.workspace_path, d.updated_at \
+ FROM chunks c \
+ JOIN documents d ON d.doc_id = c.doc_id \
+ WHERE c.chunk_id IN ({placeholders})"
+);
+```
+
+`query_map` row builder (line ~244):
+
+```rust
+ChunkMeta {
+ text: row.get(1)?,
+ heading_path_json: row.get(2)?,
+ section_label: row.get(3)?,
+ source_spans_json: row.get(4)?,
+ chunker_version: row.get(5)?,
+ doc_id: row.get(6)?,
+ workspace_path: row.get(7)?,
+ updated_at: row.get(8)?,
+}
+```
+
+The hit-construction site (line ~270-310 — `build SearchHit { ... }` block) — add:
+
+```rust
+let indexed_at = time::OffsetDateTime::parse(
+ &meta.updated_at,
+ &time::format_description::well_known::Rfc3339,
+)
+.context("kb-search vector: parse documents.updated_at as RFC3339")?;
+
+SearchHit {
+ // ... existing fields ...
+ chunker_version: ChunkerVersion(meta.chunker_version.clone()),
+ indexed_at,
+ stale: false,
+}
+```
+
+- [ ] **Step 5.4: Run test — verify it passes**
+
+```bash
+cargo test -p kebab-search vector_hit_carries_indexed_at
+```
+
+Expected: PASS.
+
+- [ ] **Step 5.5: Run full crate tests**
+
+```bash
+cargo test -p kebab-search
+```
+
+Expected: all tests pass. The fusion logic in `hybrid.rs` consumes `Vec` and just merges by `chunk_id` — `indexed_at` is preserved automatically by passing the hit struct through.
+
+- [ ] **Step 5.6: Commit**
+
+```bash
+git add crates/kebab-search/src/vector.rs crates/kebab-search/tests/
+git commit -m "$(cat <<'EOF'
+feat(search/vector): populate SearchHit.indexed_at (fb-32)
+
+hydrate_chunks now JOINs d.updated_at. Hybrid fusion path is
+unchanged (passes SearchHit through, fields preserved).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 6: App facade — staleness module + post-process
+
+**Files:**
+- Create: `crates/kebab-app/src/staleness.rs`
+- Modify: `crates/kebab-app/src/app.rs`
+- Modify: `crates/kebab-app/src/lib.rs` (module declaration)
+
+- [ ] **Step 6.1: Write the failing unit test**
+
+Create `crates/kebab-app/src/staleness.rs`:
+
+```rust
+//! p9-fb-32 staleness helpers.
+
+use time::{Duration, OffsetDateTime};
+
+use kebab_core::SearchHit;
+
+/// Returns `true` iff `now - indexed_at > threshold_days * 24h`.
+/// `threshold_days = 0` always returns `false` (feature disabled).
+/// Strict `>` so that exactly `threshold_days` old returns `false`.
+pub fn compute_stale(
+ indexed_at: OffsetDateTime,
+ now: OffsetDateTime,
+ threshold_days: u32,
+) -> bool {
+ if threshold_days == 0 {
+ return false;
+ }
+ let threshold = Duration::days(i64::from(threshold_days));
+ (now - indexed_at) > threshold
+}
+
+/// Sets `stale` on each hit in place using `compute_stale`.
+pub fn mark_stale_in_place(
+ hits: &mut [SearchHit],
+ now: OffsetDateTime,
+ threshold_days: u32,
+) {
+ for h in hits {
+ h.stale = compute_stale(h.indexed_at, now, threshold_days);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use time::macros::datetime;
+
+ fn now() -> OffsetDateTime {
+ datetime!(2026-05-09 12:00:00 UTC)
+ }
+
+ #[test]
+ fn threshold_zero_always_fresh() {
+ let very_old = datetime!(2020-01-01 00:00:00 UTC);
+ assert!(!compute_stale(very_old, now(), 0));
+ }
+
+ #[test]
+ fn just_under_threshold_is_fresh() {
+ // 29 days, 23h, 59m old — under 30d.
+ let indexed = now() - Duration::days(29) - Duration::hours(23) - Duration::minutes(59);
+ assert!(!compute_stale(indexed, now(), 30));
+ }
+
+ #[test]
+ fn exactly_threshold_is_fresh() {
+ // strict `>` boundary: exactly 30d old is still fresh.
+ let indexed = now() - Duration::days(30);
+ assert!(!compute_stale(indexed, now(), 30));
+ }
+
+ #[test]
+ fn one_minute_past_threshold_is_stale() {
+ let indexed = now() - Duration::days(30) - Duration::minutes(1);
+ assert!(compute_stale(indexed, now(), 30));
+ }
+
+ #[test]
+ fn future_indexed_at_is_fresh() {
+ // clock skew safety: future timestamps must not be stale.
+ let future = now() + Duration::hours(1);
+ assert!(!compute_stale(future, now(), 30));
+ }
+}
+```
+
+- [ ] **Step 6.2: Wire the module into the crate**
+
+Edit `crates/kebab-app/src/lib.rs` — add `mod staleness;` and a `pub use staleness::{compute_stale, mark_stale_in_place};` near the other module declarations / re-exports. (Search for `mod app;` to find the existing module declaration block.)
+
+Verify `kebab_core` is already a dependency of `kebab-app` (it is — `App` itself uses `SearchHit`).
+
+- [ ] **Step 6.3: Run tests — verify they pass**
+
+```bash
+cargo test -p kebab-app --lib staleness
+```
+
+Expected: 5 tests PASS.
+
+- [ ] **Step 6.4: Wire into `App::search` + `App::search_uncached`**
+
+Modify `crates/kebab-app/src/app.rs`:
+
+In `App::search_uncached`, after the retriever call returns hits and before `Ok(...)`:
+
+```rust
+pub fn search_uncached(&self, query: SearchQuery) -> Result> {
+ let mut hits = match query.mode {
+ SearchMode::Lexical => { /* ... existing ... */ }
+ SearchMode::Vector => { /* ... existing ... */ }
+ SearchMode::Hybrid => { /* ... existing ... */ }
+ };
+ // p9-fb-32: stamp staleness against the freshest possible `now`
+ // and the current threshold. Cheap (per-hit comparison).
+ let now = OffsetDateTime::now_utc();
+ crate::staleness::mark_stale_in_place(
+ &mut hits,
+ now,
+ self.config.search.stale_threshold_days,
+ );
+ Ok(hits)
+}
+```
+
+In `App::search` (the cache wrapper), the cached `Vec` was stamped at write time but threshold may have changed and time has moved on. Re-stamp on every cache hit:
+
+```rust
+if let Some(hits) = guard.get(&key) {
+ let mut hits = hits.clone();
+ drop(guard);
+ let now = OffsetDateTime::now_utc();
+ crate::staleness::mark_stale_in_place(
+ &mut hits,
+ now,
+ self.config.search.stale_threshold_days,
+ );
+ return Ok(hits);
+}
+```
+
+(The cache miss path already calls `search_uncached` which stamps, so no extra work needed there.)
+
+- [ ] **Step 6.5: Add integration test for `App::search` end-to-end**
+
+Create `crates/kebab-app/tests/search_stale_integration.rs`:
+
+```rust
+//! p9-fb-32: App::search wires staleness onto every hit per
+//! the configured threshold.
+
+mod common; // adapt — use whatever test scaffold the crate has
+
+use kebab_app::App;
+use kebab_core::{SearchMode, SearchQuery};
+
+#[test]
+fn fresh_doc_is_not_stale_with_default_threshold() {
+ let env = common::TestEnv::new(); // existing scaffold
+ env.ingest_md("a.md", "# T\n\napples\n");
+ let app = env.app();
+ let hits = app.search(SearchQuery {
+ text: "apples".to_string(),
+ mode: SearchMode::Lexical,
+ k: 5,
+ filters: Default::default(),
+ }).unwrap();
+ assert!(!hits.is_empty());
+ assert!(hits.iter().all(|h| !h.stale), "freshly-ingested doc must not be stale at default 30d threshold");
+}
+
+#[test]
+fn threshold_zero_disables_staleness() {
+ let env = common::TestEnv::new_with_threshold_days(0);
+ env.ingest_md_with_backdated_updated_at("a.md", "# T\n\napples\n", 365);
+ let app = env.app();
+ let hits = app.search(SearchQuery {
+ text: "apples".to_string(),
+ mode: SearchMode::Lexical,
+ k: 5,
+ filters: Default::default(),
+ }).unwrap();
+ assert!(!hits.is_empty());
+ assert!(hits.iter().all(|h| !h.stale), "threshold=0 disables staleness even for year-old docs");
+}
+
+#[test]
+fn old_doc_marked_stale() {
+ let env = common::TestEnv::new_with_threshold_days(30);
+ env.ingest_md_with_backdated_updated_at("a.md", "# T\n\napples\n", 60);
+ let app = env.app();
+ let hits = app.search(SearchQuery {
+ text: "apples".to_string(),
+ mode: SearchMode::Lexical,
+ k: 5,
+ filters: Default::default(),
+ }).unwrap();
+ assert!(hits.iter().any(|h| h.stale), "60-day-old doc must be stale at 30d threshold");
+}
+```
+
+The `ingest_md_with_backdated_updated_at` helper writes a doc through normal ingest then SQL-rewrites `documents.updated_at` to `now - days`. Implementation in `tests/common/mod.rs` (extend existing common helpers):
+
+```rust
+pub fn ingest_md_with_backdated_updated_at(&self, path: &str, body: &str, days_ago: i64) {
+ self.ingest_md(path, body);
+ let backdated = (time::OffsetDateTime::now_utc() - time::Duration::days(days_ago))
+ .format(&time::format_description::well_known::Rfc3339)
+ .unwrap();
+ let conn = rusqlite::Connection::open(self.sqlite_path()).unwrap();
+ conn.execute(
+ "UPDATE documents SET updated_at = ?1 WHERE workspace_path = ?2",
+ rusqlite::params![backdated, path],
+ ).unwrap();
+}
+```
+
+If `TestEnv::new_with_threshold_days` doesn't exist, add it as a thin wrapper that builds a `Config` with the override applied before `App::open_with_config`.
+
+- [ ] **Step 6.6: Run integration tests**
+
+```bash
+cargo test -p kebab-app --test search_stale_integration
+```
+
+Expected: 3 tests PASS.
+
+- [ ] **Step 6.7: Commit**
+
+```bash
+git add crates/kebab-app/src/staleness.rs crates/kebab-app/src/lib.rs crates/kebab-app/src/app.rs crates/kebab-app/tests/
+git commit -m "$(cat <<'EOF'
+feat(app): staleness module + post-process search hits (fb-32)
+
+compute_stale: strict > boundary, threshold=0 disables, future
+timestamps treated as fresh (clock skew safety). App::search
+re-stamps on cache hit so config threshold changes take effect
+without flushing the cache.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 7: App facade — `AnswerCitation` staleness in `App::ask`
+
+**Files:**
+- Modify: `crates/kebab-app/src/app.rs` (or wherever `App::ask` lives)
+
+- [ ] **Step 7.1: Locate `App::ask` AnswerCitation construction**
+
+```bash
+grep -n "AnswerCitation\|fn ask\b" crates/kebab-app/src/app.rs crates/kebab-app/src/lib.rs
+```
+
+Identify the spot where `Answer` is built (likely in `App::ask` around line 256+ in `app.rs`, or in a helper called from there). The `Vec` is constructed by mapping over the underlying retrieval hits.
+
+- [ ] **Step 7.2: Write the failing test**
+
+Append to `crates/kebab-app/tests/search_stale_integration.rs`:
+
+```rust
+#[test]
+fn ask_citation_carries_indexed_at_and_stale() {
+ let env = common::TestEnv::new_with_threshold_days(30);
+ env.ingest_md_with_backdated_updated_at("a.md", "# T\n\napples are fruit\n", 60);
+ let app = env.app();
+ let answer = app.ask("apples", Default::default()).unwrap();
+ assert!(!answer.citations.is_empty());
+ assert!(
+ answer.citations.iter().any(|c| c.stale),
+ "60d-old cited doc must surface stale=true"
+ );
+ let now = time::OffsetDateTime::now_utc();
+ for c in &answer.citations {
+ // indexed_at populated, not the zero-time default
+ assert!((now - c.indexed_at).whole_seconds() > 0);
+ }
+}
+```
+
+If `App::ask` requires a real LLM, gate this test behind the same feature / env var existing ask integration tests use (search for an existing ask integration test in `kebab-app/tests/` for the pattern). If no LLM is available in CI, add the test under `#[cfg(test)]` with the same skip guard the existing tests use.
+
+- [ ] **Step 7.3: Run test — verify it fails**
+
+```bash
+cargo test -p kebab-app --test search_stale_integration ask_citation_carries
+```
+
+Expected: FAIL — `AnswerCitation.indexed_at` is zero-time (default), `stale` is false.
+
+- [ ] **Step 7.4: Implement — populate from retrieval hits**
+
+In `App::ask`, the retrieval step produces `Vec` (already stamped with `indexed_at` + `stale` by Task 6's post-processing). When constructing `AnswerCitation` from each hit, copy both fields:
+
+```rust
+let citations: Vec = hits
+ .iter()
+ .map(|h| AnswerCitation {
+ marker: build_marker(h), // existing logic
+ citation: h.citation.clone(),
+ indexed_at: h.indexed_at,
+ stale: h.stale,
+ })
+ .collect();
+```
+
+If the construction site uses a different builder pattern, adapt to match — the principle is the citation pulls both fields from the source `SearchHit`.
+
+- [ ] **Step 7.5: Run test — verify it passes**
+
+```bash
+cargo test -p kebab-app --test search_stale_integration ask_citation_carries
+```
+
+Expected: PASS.
+
+- [ ] **Step 7.6: Commit**
+
+```bash
+git add crates/kebab-app/
+git commit -m "$(cat <<'EOF'
+feat(app): AnswerCitation inherits indexed_at + stale from hit (fb-32)
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 8: Wire schema — required fields
+
+**Files:**
+- Modify: `docs/wire-schema/v1/search_hit.schema.json`
+- Modify: `docs/wire-schema/v1/citation.schema.json`
+
+- [ ] **Step 8.1: Update search_hit.schema.json**
+
+Edit `docs/wire-schema/v1/search_hit.schema.json`. Add to `required`:
+
+```json
+"required": [
+ "schema_version",
+ "rank",
+ "score",
+ "chunk_id",
+ "doc_id",
+ "doc_path",
+ "heading_path",
+ "snippet",
+ "citation",
+ "retrieval",
+ "index_version",
+ "chunker_version",
+ "indexed_at",
+ "stale"
+]
+```
+
+Add to `properties`:
+
+```json
+"indexed_at": { "type": "string", "format": "date-time" },
+"stale": { "type": "boolean" }
+```
+
+- [ ] **Step 8.2: Update citation.schema.json**
+
+Edit `docs/wire-schema/v1/citation.schema.json`. Add to `required`:
+
+```json
+"required": ["schema_version", "kind", "path", "uri", "indexed_at", "stale"]
+```
+
+Add to `properties`:
+
+```json
+"indexed_at": { "type": "string", "format": "date-time" },
+"stale": { "type": "boolean" }
+```
+
+- [ ] **Step 8.3: Find and update any wire schema test**
+
+```bash
+grep -rln "search_hit.schema.json\|citation.schema.json" crates/ tests/ 2>/dev/null
+```
+
+For each file using JSON Schema validation against these schemas, run its tests:
+
+```bash
+cargo test --workspace wire_schema 2>&1 | head -40
+```
+
+If any test fails because it generates a hit without `indexed_at`/`stale` for validation, the test fixture needs a regen — this is expected churn and the test will fix itself once Task 9's CLI emit path is in place. If a test asserts the absence of these fields, that's a failing assertion that needs the fields added to the expected fixture.
+
+- [ ] **Step 8.4: Commit**
+
+```bash
+git add docs/wire-schema/v1/
+git commit -m "$(cat <<'EOF'
+feat(wire): search_hit.v1 + citation.v1 require indexed_at + stale (fb-32)
+
+Additive minor — schema_version unchanged. Existing v1 consumers
+that ignore unknown fields stay compatible; consumers that validate
+strictly will reject pre-fb-32 payloads, which matches the wire
+contract escape hatch (recipient version >= producer required).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 9: CLI plain renderer — `[stale]` tag
+
+**Files:**
+- Modify: the CLI plain output renderer (locate via grep below)
+
+- [ ] **Step 9.1: Locate the plain renderer**
+
+```bash
+grep -rn "fn render\|render_hit\|render_search\|fn print_hit\|fn fmt_hit" crates/kebab-cli/src/ 2>/dev/null | head -20
+```
+
+The non-`--json` search output renderer prints rank, score, doc_path, snippet for each hit. Identify the function (likely in `crates/kebab-cli/src/main.rs` or a `render.rs` / `format.rs` sibling).
+
+- [ ] **Step 9.2: Write the failing CLI integration test**
+
+Create `crates/kebab-cli/tests/wire_search_stale.rs`:
+
+```rust
+//! p9-fb-32: CLI emits indexed_at + stale on JSON; plain output
+//! gains [stale] tag.
+
+mod common; // adapt to existing scaffold
+
+#[test]
+fn search_json_includes_indexed_at_and_stale() {
+ let out = common::run_cli_search_json(&["apples"]);
+ let arr: serde_json::Value = serde_json::from_str(&out.stdout).unwrap();
+ let first = arr.as_array().unwrap().first().unwrap();
+ assert!(first.get("indexed_at").is_some());
+ assert!(first.get("stale").is_some());
+ assert_eq!(first["stale"], false);
+}
+
+#[test]
+fn search_plain_marks_stale_doc() {
+ let env = common::CliEnv::new_with_threshold_days(30);
+ env.ingest_md_backdated("a.md", "apples", 60);
+ let out = env.run_search_plain("apples");
+ assert!(out.stdout.contains("[stale]"), "stale tag missing in plain output:\n{}", out.stdout);
+}
+```
+
+- [ ] **Step 9.3: Run tests — verify they fail**
+
+```bash
+cargo test -p kebab-cli --test wire_search_stale
+```
+
+Expected: FAIL — plain output has no `[stale]` (JSON should already pass thanks to Task 1's serde derive).
+
+- [ ] **Step 9.4: Implement plain renderer**
+
+In the located plain renderer function, prepend `[stale] ` to the doc_path line when `hit.stale == true`. Apply ANSI yellow color when `is_terminal::is_terminal(&io::stderr())` (or whatever TTY-detect helper the crate already uses — search for `is_terminal` to find the convention):
+
+```rust
+fn render_hit_plain(out: &mut impl Write, hit: &SearchHit, color: bool) -> io::Result<()> {
+ let stale_tag = if hit.stale {
+ if color {
+ "\x1b[33m[stale]\x1b[0m " // yellow
+ } else {
+ "[stale] "
+ }
+ } else {
+ ""
+ };
+ writeln!(
+ out,
+ "{rank}. {stale}{path} § {heading}",
+ rank = hit.rank,
+ stale = stale_tag,
+ path = hit.doc_path.0,
+ heading = hit.heading_path.last().map(String::as_str).unwrap_or(""),
+ )?;
+ // ... existing score / snippet lines unchanged ...
+ Ok(())
+}
+```
+
+The exact format string must match what the existing renderer emits — DO NOT reinvent the layout. The change is just the `[stale] ` prefix when applicable. Match whatever format `render_hit_plain` (or its actual name) currently produces; only prepend the tag.
+
+- [ ] **Step 9.5: Run tests — verify they pass**
+
+```bash
+cargo test -p kebab-cli --test wire_search_stale
+```
+
+Expected: PASS.
+
+- [ ] **Step 9.6: Commit**
+
+```bash
+git add crates/kebab-cli/
+git commit -m "$(cat <<'EOF'
+feat(cli): [stale] tag on plain output (fb-32)
+
+Yellow when TTY, plain when not. JSON path inherits via serde
+on the domain type; no CLI-side wire change needed there.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 10: CLI ask renderer — citation `[stale]` tag
+
+**Files:**
+- Modify: CLI plain answer/citation renderer
+- Create: `crates/kebab-cli/tests/wire_ask_stale.rs`
+
+- [ ] **Step 10.1: Locate the ask plain renderer**
+
+```bash
+grep -rn "render_answer\|print_answer\|render_citation\|print_citation\|fn fmt_answer" crates/kebab-cli/src/ 2>/dev/null | head -10
+```
+
+- [ ] **Step 10.2: Write the failing test**
+
+Create `crates/kebab-cli/tests/wire_ask_stale.rs`:
+
+```rust
+mod common;
+
+#[test]
+fn ask_json_citations_include_indexed_at_and_stale() {
+ let env = common::CliEnv::new_with_threshold_days(30);
+ env.ingest_md_backdated("a.md", "apples are fruit", 60);
+ let out = env.run_ask_json("what about apples");
+ let answer: serde_json::Value = serde_json::from_str(&out.stdout).unwrap();
+ let cit = answer["citations"].as_array().unwrap().first().unwrap();
+ assert!(cit.get("indexed_at").is_some());
+ assert_eq!(cit["stale"], true);
+}
+
+#[test]
+fn ask_plain_marks_stale_citation() {
+ let env = common::CliEnv::new_with_threshold_days(30);
+ env.ingest_md_backdated("a.md", "apples are fruit", 60);
+ let out = env.run_ask_plain("what about apples");
+ assert!(out.stdout.contains("[stale]"));
+}
+```
+
+Same LLM-availability gating as Task 7's ask test if the CLI test scaffold doesn't already cover it.
+
+- [ ] **Step 10.3: Run tests — verify they fail**
+
+```bash
+cargo test -p kebab-cli --test wire_ask_stale
+```
+
+Expected: PASS for JSON (serde auto), FAIL for plain output.
+
+- [ ] **Step 10.4: Implement plain citation renderer**
+
+Same pattern as Task 9 but applied to the citation render function. The citation line in plain ask output gains `[stale] ` prefix when `citation.stale == true`.
+
+- [ ] **Step 10.5: Run tests — verify they pass**
+
+```bash
+cargo test -p kebab-cli --test wire_ask_stale
+```
+
+Expected: PASS.
+
+- [ ] **Step 10.6: Commit**
+
+```bash
+git add crates/kebab-cli/
+git commit -m "$(cat <<'EOF'
+feat(cli): [stale] tag on plain ask citations (fb-32)
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 11: TUI — `[STALE]` Span on search/inspect/ask panes
+
+**Files:**
+- Modify: TUI search pane render (locate via grep)
+- Modify: TUI inspect pane render
+- Modify: TUI ask citations render
+
+- [ ] **Step 11.1: Locate TUI render sites**
+
+```bash
+grep -rn "doc_path\|workspace_path\.0\|hit\.doc_path\|render_hit\|render_search" crates/kebab-tui/src/ 2>/dev/null | head -30
+grep -rn "Role::Warning\|Theme::style" crates/kebab-tui/src/ 2>/dev/null | head -10
+```
+
+Identify the spots where `hit.doc_path` and `citation.path` get spanned for display in search / inspect / ask panes.
+
+- [ ] **Step 11.2: Write the failing snapshot test**
+
+Find the existing snapshot test for the search pane (likely `crates/kebab-tui/tests/search_pane.rs` or similar). Add or modify a test that ingests a doc, backdates `documents.updated_at`, runs a search, and snapshots the pane. The snapshot must include the `[STALE]` text.
+
+```rust
+#[test]
+fn search_pane_shows_stale_badge_for_old_doc() {
+ let mut env = TuiTestEnv::new_with_threshold_days(30);
+ env.ingest_md_backdated("a.md", "apples", 60);
+ let pane = env.run_search_pane("apples");
+ insta::with_settings!({
+ filters => vec![
+ // p9-fb-32: indexed_at is time-dependent — mask in snapshots.
+ (r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", "[indexed_at]"),
+ ],
+ }, {
+ insta::assert_snapshot!(pane);
+ });
+}
+```
+
+- [ ] **Step 11.3: Run snapshot test — verify it fails or pending**
+
+```bash
+cargo test -p kebab-tui search_pane_shows_stale_badge
+```
+
+Expected: pending (no snapshot yet) or fail (existing snapshot lacks `[STALE]`).
+
+- [ ] **Step 11.4: Implement the badge**
+
+In each render site (search hit row, inspect header, ask citation), wrap a `[STALE]` Span with the Warning style when `hit.stale == true`:
+
+```rust
+let mut spans: Vec = vec![
+ Span::raw(format!("{}. ", hit.rank)),
+];
+if hit.stale {
+ spans.push(Span::styled("[STALE] ", theme.style(Role::Warning)));
+}
+spans.push(Span::raw(hit.doc_path.0.clone()));
+// ... rest of the row
+```
+
+- [ ] **Step 11.5: Accept the new snapshot**
+
+```bash
+cargo test -p kebab-tui search_pane_shows_stale_badge -- --nocapture
+cargo insta review
+```
+
+Inspect the snapshot — the `[STALE]` text must appear before the doc_path on the stale row. Accept.
+
+- [ ] **Step 11.6: Update insta filter for existing snapshots**
+
+Existing TUI snapshots may now contain `indexed_at` or other timestamp-bearing diffs. Run the broader TUI test:
+
+```bash
+cargo test -p kebab-tui
+```
+
+For each insta failure, inspect with `cargo insta review`. If the only diff is a serialized `indexed_at`, add the filter pattern from Step 11.2 to the test in question. If the diff is the new `[STALE]` text on a row that should now be marked stale, accept. Reject anything else and investigate.
+
+- [ ] **Step 11.7: Commit**
+
+```bash
+git add crates/kebab-tui/
+git commit -m "$(cat <<'EOF'
+feat(tui): [STALE] Warning-styled badge on search/inspect/ask (fb-32)
+
+insta filter pattern '[indexed_at]' applied where snapshots
+otherwise capture time-dependent RFC3339 strings.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 12: Snapshot fan-out — workspace-wide insta sweep
+
+**Files:**
+- Modify: any insta snapshot under `crates/*/tests/snapshots/` that now contains `indexed_at`
+
+- [ ] **Step 12.1: Run full workspace test sequentially**
+
+```bash
+cargo test --workspace --no-fail-fast -j 1 2>&1 | tail -120
+```
+
+(`-j 1` per CLAUDE.md to avoid linker OOM.)
+
+- [ ] **Step 12.2: For each snapshot diff: classify**
+
+```bash
+cargo insta pending-snapshots
+```
+
+For each pending:
+
+- **Diff is only `indexed_at`** (new RFC3339 field): add the filter pattern `(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", "[indexed_at]")` to the test's `with_settings!` block, OR if the snapshot already has many time fields, add the filter at module level. Re-run + accept.
+- **Diff is `stale: false` field appearing**: accept (additive, expected).
+- **Diff is `[STALE]` text on a stale doc row**: accept (expected from Task 11).
+- **Diff is anything else**: reject and investigate — that's a regression.
+
+- [ ] **Step 12.3: Accept reviewed snapshots**
+
+```bash
+cargo insta accept
+```
+
+Verify with:
+
+```bash
+git diff --stat crates/*/tests/snapshots/
+```
+
+The diff should be confined to insta `.snap` files plus filter additions in test files.
+
+- [ ] **Step 12.4: Run workspace tests again — must be all-green**
+
+```bash
+cargo test --workspace --no-fail-fast -j 1
+```
+
+Expected: all PASS.
+
+- [ ] **Step 12.5: Commit**
+
+```bash
+git add crates/*/tests/ crates/*/src/
+git commit -m "$(cat <<'EOF'
+test(snapshots): regen for indexed_at + stale fields (fb-32)
+
+insta filter '[indexed_at]' applied where time-dependent.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 13: Clippy + workspace check
+
+- [ ] **Step 13.1: Run clippy**
+
+```bash
+cargo clippy --workspace --all-targets -- -D warnings
+```
+
+Expected: 0 warnings. Fix any introduced warnings inline.
+
+- [ ] **Step 13.2: Commit if clippy required fixes**
+
+```bash
+git add -A
+git commit -m "chore: clippy fixes for fb-32"
+```
+
+(Skip this commit if no fixes were needed.)
+
+---
+
+## Task 14: Documentation updates
+
+**Files:**
+- Modify: `README.md`
+- Modify: `docs/SMOKE.md`
+- Modify: `tasks/p9/p9-fb-32-stale-doc-indicator.md`
+- Modify: `tasks/INDEX.md`
+- Modify: `integrations/claude-code/kebab/SKILL.md`
+
+- [ ] **Step 14.1: README — Configuration section**
+
+Find the Configuration section (search for `## Configuration` or the `config.toml` example block):
+
+```bash
+grep -n "stale_threshold_days\|\\[search\\]" README.md
+```
+
+Add to the example `[search]` block:
+
+```toml
+[search]
+default_k = 10
+hybrid_fusion = "rrf"
+rrf_k = 60
+snippet_chars = 220
+stale_threshold_days = 30 # 0 = disable. Marks hits/citations whose source doc was last reindexed > N days ago.
+```
+
+- [ ] **Step 14.2: docs/SMOKE.md — config example + walkthrough**
+
+Add the same line to the SMOKE config example. After the existing search walkthrough, append a short paragraph:
+
+```markdown
+### Stale doc indicator
+
+Each search hit and RAG citation carries `indexed_at` (RFC3339 of the doc's last
+re-process) and `stale` (computed against `[search] stale_threshold_days`).
+A 30-day default flags docs that haven't been touched in a month — the
+intent is to nudge a reingest before relying on the snapshot. Set to `0`
+to disable.
+```
+
+- [ ] **Step 14.3: Task spec status flip**
+
+Edit `tasks/p9/p9-fb-32-stale-doc-indicator.md`:
+
+```diff
+ ---
+ phase: P9
+ component: kebab-app + kebab-tui + kebab-cli
+ task_id: p9-fb-32
+ title: "Stale doc indicator (ingest 시점 대비 X 일 임계 알림)"
+-status: open
++status: completed
+ target_version: 0.4.0
+```
+
+Replace the body's `> ⏳ **백로그 only — 미구현.**` block with:
+
+```markdown
+상세 설계: `docs/superpowers/specs/2026-05-08-p9-fb-32-stale-doc-indicator-design.md`.
+구현 계획: `docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md`.
+```
+
+(Keep the rest of the spec body — it's the historical contract per CLAUDE.md.)
+
+- [ ] **Step 14.4: tasks/INDEX.md — fb-32 row**
+
+Edit `tasks/INDEX.md`:
+
+```diff
+- - [p9-fb-32 stale doc indicator](p9/p9-fb-32-stale-doc-indicator.md) — ⏳ 미구현, brainstorm 필요
++ - [p9-fb-32 stale doc indicator](p9/p9-fb-32-stale-doc-indicator.md) — ✅ 머지 + v0.4.0 cut 후보 (2026-05-09)
+```
+
+- [ ] **Step 14.5: Skill — parsing tip**
+
+Edit `integrations/claude-code/kebab/SKILL.md` — locate the "Parsing tips" section and append a bullet:
+
+```markdown
+- `search_hit.v1` and `answer.v1.citations[]` carry `indexed_at` (RFC3339) + `stale` (bool). When `stale == true`, the source doc hasn't been re-processed since `config.search.stale_threshold_days`. Surface this caveat to the user when summarizing — the cited snapshot may not reflect current reality.
+```
+
+- [ ] **Step 14.6: Commit docs**
+
+```bash
+git add README.md docs/SMOKE.md tasks/p9/p9-fb-32-stale-doc-indicator.md tasks/INDEX.md integrations/claude-code/kebab/SKILL.md
+git commit -m "$(cat <<'EOF'
+docs(fb-32): README + SMOKE + INDEX + skill parsing tip
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 15: Smoke + final verification
+
+- [ ] **Step 15.1: Manual smoke against `docs/SMOKE.md`**
+
+Follow the SMOKE walkthrough end-to-end:
+
+```bash
+mkdir -p /tmp/kebab-smoke && cd /tmp/kebab-smoke
+# (build a minimal config.toml + workspace per docs/SMOKE.md)
+~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml init
+~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml ingest
+~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml search "test" --json | jq '.[0] | {indexed_at, stale}'
+```
+
+Expected output:
+
+```json
+{"indexed_at": "2026-05-09T...Z", "stale": false}
+```
+
+- [ ] **Step 15.2: Backdate + re-verify stale path**
+
+```bash
+sqlite3 /tmp/kebab-smoke/data/kebab.sqlite "UPDATE documents SET updated_at = '2025-01-01T00:00:00Z' WHERE workspace_path LIKE '%test%';"
+~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml search "test" --json | jq '.[0].stale'
+```
+
+Expected: `true`.
+
+- [ ] **Step 15.3: Plain output check**
+
+```bash
+~/Workspace/projects/kebab/target/release/kebab --config /tmp/kebab-smoke/config.toml search "test"
+```
+
+Expected: `[stale]` tag present on the matched hit.
+
+- [ ] **Step 15.4: Final workspace test**
+
+```bash
+cd ~/Workspace/projects/kebab
+cargo test --workspace --no-fail-fast -j 1
+```
+
+Expected: all green.
+
+- [ ] **Step 15.5: Push + open PR**
+
+```bash
+git push -u origin feat/fb-32-stale-doc-indicator
+```
+
+Open PR via Gitea API (per CLAUDE.md — `gh` does not work):
+
+```bash
+curl -s --netrc-file ~/.netrc \
+ -X POST \
+ -H "Content-Type: application/json" \
+ https://gitea.altair823.xyz/api/v1/repos/altair823-org/kebab/pulls \
+ -d '{
+ "title": "feat(fb-32): stale doc indicator",
+ "body": "## Summary\n- adds `indexed_at` + `stale` to `search_hit.v1` / `citation.v1`\n- reuses `documents.updated_at` (no migration)\n- config `search.stale_threshold_days` default 30; 0 disables\n- TUI `[STALE]` Warning badge, CLI `[stale]` tag, agent JSON fields\n\n## Test plan\n- [x] cargo test --workspace -j 1 green\n- [x] cargo clippy --workspace --all-targets -- -D warnings\n- [x] manual smoke: ingest → search shows fresh; backdate → search shows stale\n- [x] insta snapshots reviewed and accepted\n\nSpec: `docs/superpowers/specs/2026-05-08-p9-fb-32-stale-doc-indicator-design.md`\nPlan: `docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md`",
+ "head": "feat/fb-32-stale-doc-indicator",
+ "base": "main"
+ }'
+```
+
+Capture the returned PR URL.
+
+---
+
+## Self-review checklist (post-plan, pre-execution)
+
+- **Spec coverage:**
+ - §Behavior contract → Tasks 1, 2, 6 (domain + compute_stale)
+ - §Wire schema delta → Task 8
+ - §Config → Task 3
+ - §CLI plain output → Tasks 9, 10
+ - §TUI → Task 11
+ - §Components → Tasks 4 (lexical), 5 (vector), 6/7 (app), 9/10 (cli), 11 (tui)
+ - §Test plan → unit (Tasks 3, 6), integration (Tasks 6, 7, 9, 10, 11)
+ - §Documentation → Task 14
+ - §Risks/Clock → Task 6 (explicit `now: OffsetDateTime` arg, no Clock trait)
+ - §Risks/Snapshot churn → Task 12
+ - §Risks/Off-by-one → Task 6 unit tests `exactly_threshold_is_fresh` + `one_minute_past_threshold_is_stale`
+
+- **Placeholder scan:**
+ - "adapt to existing scaffold" appears in Tasks 4, 5, 6, 7, 9 — these instruct copying from existing test infrastructure rather than inventing new helpers. The intent is concrete (find `TestEnv` / `common`, mirror the pattern). Acceptable since fully spelling out an existing scaffold would inflate the plan and the code is in the repo.
+ - No "TODO", "later", or "fill in" remaining.
+
+- **Type consistency:**
+ - `indexed_at: OffsetDateTime` and `stale: bool` consistent across `SearchHit`, `AnswerCitation`, `compute_stale`, `mark_stale_in_place`.
+ - `threshold_days: u32` consistent in `SearchCfg` + helpers.
+ - Function `mark_stale_in_place(&mut [SearchHit], OffsetDateTime, u32)` — same signature in Tasks 6.4, 6.5, 6.6.
+
+- **Spec deviation noted:**
+ - Spec §Components says "kebab-core 변경 없음". Plan correctly identifies this as inaccurate (domain `SearchHit` IS the wire source) and updates kebab-core. The spec body should be amended in Task 14 if strict alignment matters; currently the spec § Public surface delta block already shows the kebab-core changes implicitly.
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-05-09-p9-fb-32-stale-doc-indicator.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration.
+
+**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+Which approach?