# Phase 53 — Invalidate Saved Chats on Sources Sync **Source:** `TODO.md` L4 — "Make sure the saved chats are invalidated if the docs are synced, that way it generates a new answer with new data" **Story:** n/a (TODO-derived — owner instruction 2026-08-30: convert without confirmation) **Context:** Phase 50 stores explicitly saved conversations in `saved_chats` (JSONB `bor.chat.v1` records; admin-only CRUD under `/api/chats` in `app/api/chats.py`; the History page is a full-width table per AGENTS.md rule 5; `/?chat=` re-opens a row pixel-identical through the phase-14 restore path). Phase 51 added the public snapshot read (`/shared/` → `SharedChatOut`, no admin dependency). Sources are synced through two canonical paths: the admin **Sync** button (`POST /api/sync` → `app/api/sync.py::_run_sync`: `check_models` → resolve effective sources → clone/pull or local-dir re-verify → `import_sources(prune=True)` → change-gated `regenerate_overview`) and the CLI/quadlet `scripts/import_docs.py` (same `import_sources`; `--limit` debug runs and unchanged re-imports are change-gated on `added + updated`). Neither path records *when the KB last changed*, so a saved answer can silently predate the current index. `retryLastTurn(wrap)` in `frontend/assets/app.js` (phase 49) is the existing redo-in-place mechanism for the LAST brain bubble (re-asks the preceding user question, `runTurn(text, { reask: true })`, no user append, no scroll) — the Regenerate action reuses it. Single-row table precedent: `kb_overview` (id = 1, `app/models.py::KbOverview`). ## Objective Every sync that actually changes the knowledge base bumps a sources version; saved chats stamp that version at save time; a chat saved against an older version is surfaced as **stale** (History table badge + a banner when opened) with a **Regenerate** action that re-asks the last question against the new index and re-saves the row — a stale answer can no longer masquerade as current. ## Dependencies - `50_chat_history` (complete) — the `saved_chats` row, `/api/chats` CRUD, the `?chat=` boot load, the linked-row Save upsert (`saveCurrentChat` in `app.js`). - `51_share_chat` (complete) — the public `/shared/` snapshot read (stays a frozen snapshot; untouched by this phase). - `49_retry_answer` (complete) — the `retryLastTurn` redo-in-place the Regenerate action drives. - `52_pinned_composer` (todo, preceding) — the chat-page layout is stable while the banner lands; ordering keeps the chat UI quiet (no shared-file conflict beyond `styles.css`/`app.js` regions). ## Tasks 1. `01_sources_version_and_migration.md` — the single-row `sources_meta` table + `saved_chats.sources_version` (migration 0010) + the current/bump helpers + the migration test. 2. `02_sync_version_bump.md` — the version bump on both sync paths (Sync button + CLI), change-gated. 3. `03_chats_api_staleness.md` — stamp-on-save + the `stale` flag on the list/detail responses. 4. `04_history_stale_badge.md` — the Stale column on the History table. 5. `05_stale_banner_and_regenerate.md` — the chat-page banner + Regenerate (`retryLastTurn` reuse) + the auto re-save. 6. `06_e2e_stale_saved_chats.md` — the story Playwright suite + regressions + commit. ## Testing & Quality - Unit: `tests/unit/test_sources_meta.py` (helpers: absent row → 0, first bump → 1, second bump → 2, idempotent reads); the sync bump gates — extend the existing sync coverage (`tests/integration/test_sync_api.py` + the `tests/fakes.py` override patterns) and the CLI coverage (`tests/integration/test_import_docs_overview.py` pins the change-gating pattern; the bump asserts sit alongside). - Integration: `tests/integration/test_migration_0010.py` (house pattern, from `test_migration_0009.py`); extend `tests/integration/test_chats_api.py` (stamp + `stale` flag + share-unshare version immunity). - Frontend source pins (house style): `history.js` stale-cell branch, `app.js` banner reveal / Regenerate wiring / post-regenerate persist / no-brain-bubble guard (the `test_history_page.py` / `test_save_chat_ui.py` pin patterns). - Coverage: **>90%** on `app/` (validate.sh gate). - E2E (mandatory, A16): `tests/e2e/test_stale_saved_chats.py`, run in isolation. ## Completion Criteria - [ ] A KB-changing sync (button or CLI) bumps `sources_meta.version` exactly once; an unchanged re-run, a `--limit` debug run, and a FAILED sync never bump. - [ ] A Save/Re-Save stamps the row's `sources_version`; `GET /api/chats` + `GET /api/chats/` expose `stale` (true iff the row's version is behind the current one); share/unshare never touch the version. - [ ] The History table shows the Stale marker exactly on rows saved before the last KB-changing sync (full-width table geometry unchanged, AGENTS.md rule 5). - [ ] Opening a stale `/?chat=` shows the banner; Regenerate re-streams the last answer in place against the new index and re-saves the row — the banner clears, `GET /api/chats/` reports `stale: false`, the History marker is gone. - [ ] A `/shared/` page is unchanged — the public snapshot carries no staleness surface. - [ ] `uv run pytest` green; coverage TOTAL >90%. - [ ] `uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov` green in isolation (DB up). - [ ] Regression E2E suites green in isolation: `test_chat_history.py`, `test_share_chat.py`, `test_sync_button.py`, `test_retry_answer.py`, `test_chat_persistence.py`. - [ ] `uv run ruff check . && uv run pyright` clean. - [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`. ## Locked decisions - **Recorded assumptions (TODO conversion, 2026-08-30 — owner asked for no confirmation):** 1. The invalidation marker is a monotonically increasing `sources_meta.version` (single row, the `kb_overview` id=1 precedent), bumped ONLY when a sync changed the KB — the gate is `added + updated + pruned > 0` (pruned counts: a deleted doc can invalidate an answer that cited it — deliberately broader than the overview gate's `added + updated > 0`). 2. `stale = row.sources_version < current`, computed server-side in the chats API; the client never computes staleness. Pre-existing rows stamp 0 (the pre-counter KB) and go stale on the first bump. 3. Regenerate = the phase-49 redo-in-place of the LAST brain bubble (re-ask the last user question with the full conversation context) followed by an auto re-save of the linked row — the owner does not press Save again; a stale chat with no brain answer shows the banner text without a Regenerate button. 4. The public shared snapshot (`/shared/`) is deliberately untouched — a frozen snapshot by design; an owner who regenerates can re-share afterwards. 5. A failed sync (git/embed/import error) aborts BEFORE the bump — a failed sync never invalidates chats; the bump commits even if the best-effort overview regeneration then fails (the index really did change). - **A10 honoured** — `/api/chat` stays stateless; staleness is a property of the explicitly saved row, not of the chat endpoint. - **A16/A17 honoured** — one story E2E suite, one atomic commit. ## Commit ```bash git add -A .agent/ app/ alembic/versions/ scripts/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index" ```