# Phase 106 — Document dates end to end: sourced at sync, displayed, editable, recency-weighted in retrieval **Source:** Owner request 2026-09-13 (chat): documents "all have dates, either via their git timestamp or via file metadata (hopefully) preserved in the tar or zip archive process"; "After this phase, all documents must include the date when fed to the LLM"; "The UI must also show a date for every document at the top of that document when the user clicks it"; "If the date of a document can't be determined or is in the future then assume that document was created today"; "Date should be stored in the bor database and updated when sources are synced"; "Newer documents should be ranked higher in retrieval somehow, or at least given a boost, without breaking the existing retrieval process"; "last updated dates/timestamps on folders before the description column but after the documents column"; "For files, include a date/timestamp before the 'indexed' column"; "This timestamp should be editable so users can correct for errors"; "the timestamp can't be null so just set it to today's date during the migration and then, on sync, update the date"; "It's totally fine for a sync to cause a document or folder's date to get older." **Story:** n/a (owner roadmap confirmation — the phase's E2E suite proves the item end to end). **Context:** Every imported file becomes a `documents` row (`app/models.py` L101) with `indexed_at` (L112, the INDEX time — a different concept from this phase's document *creation* date, which it does not touch) and a nullable `summary`. The importer (`app/rag/importer.py`) walks each source root (`iter_importable_files` L171-194) and upserts per file in `_index_file` (L376-471): content-hash delta — `added`/`updated` (the row write, L405-421) or the early-return `unchanged` path (L401-404, **no write today**); `import_sources` (L213-340) already carries per-root maps keyed by `str(root)` — `ignore_by_root` (phase 89) and `include_hidden_by_root` (phase 105) — with the progress pre-walk and prune using the same per-root resolution; `ImportSummary` (L64-111) logs the greppable `import: summary files=…` line (PLAN §9). The two live entry points both resolve the `git_sources` rows, clone git rows through `scripts/git_sync.clone_or_pull` (`app/api/sync.py::_run_sync` per-row loop L233-263, import call L300-303; `scripts/import_docs.py::_resolve_sources` L168-226, clone L220, import call L331-334) and pass the per-root maps through — this phase adds a THIRD map (task 04). **Verified git behavior (2026-09-13, scratch repos):** `git clone --depth 1 /local/path` warns "--depth is ignored in local clones" and keeps FULL history, so local-path git sources can yield TRUE per-file last-commit dates; URL-transport clones (https/ssh/`file://`) are shallow, and in a shallow clone `git log -1 --format=%cI -- ` returns the TIP commit's date for EVERY existing file (the shallow boundary is each file's history root — a uniform per-repo date, real across repos, flat within one). `git log --name-only --format=@@%cI` walks newest-first and lists every working-tree file under the shallow tip. The archive unpacker (`app/rag/archive_upload.py`, phase 49) streams member content (`_unpack_zip` L214-238, `_unpack_tar` L241-270) but **never restores member mtimes** — extracted files carry the extraction time, so uploads today lose their dates (the owner's "hopefully" — this phase fixes it, task 03). Retrieval (A7): cosine top-100 ∪ FTS top-30 → RRF `fuse()` (k=60, `app/rag/retriever.py` L225-258) → `select_documents` (L540+, sorts chunks by fused `score`, dedupes parents, top-N=2 full texts, never truncated); the honesty gate and `query_log.top_score` are COSINE-based (untouched by a score-side boost); lexical candidates are detached `Document` rows rebuilt from two raw SQL projections (`_LEXICAL_SQL` L140-157, `_NAME_HIT_SQL` L277-300) that must gain the new column (task 06); the vector path returns ORM rows (the column comes for free). LLM surfaces: the HIGH prompt's `` block (the pinned shape, `app/rag/prompts.py` L371-376: `source`/`path`/`title` attributes + full text), the `read` tool result (`app/rag/agent.py` L1173-1185: first line `Document {source}/{path}:` — the E2E mock's `_READ_RESULT_PREFIX` header contract, `tests/e2e/mock_llm.py` L867), and the `ls` file line (`render_folder_listing` L856-905: `source: X | path: Y | title: Z`, fed by `_source_document_rows` L658-669 + the pure `group_folder_listing` L707-780, cross-checked level-for-level against the UI tree builder in `app/api/docs.py` L326/L376). The E2E mock parses prompts/tool results with regexes that must stay compatible: `_DOCUMENT_BLOCK_RE` (L785-789 — breaks unless updated for the new attribute), `_CATALOG_LINE_RE` (L873-875 — `title: .+$` tolerates an APPENDED ` | date: …` field), `_READ_RESULT_PREFIX` (L867 — header line must stay byte-identical). UI: the RAG view's file table header `Source | Path | Title | Chunks | Indexed` (`frontend/index.html` L485-492, rows built by `makeRow` in `frontend/assets/sources.js` L1375-1410 — cell order `[title, chunks, indexed_at]` L1401) and the folder/source table `Folder | Documents | Description` (L470-478, rows `makeSourceRow` L1217-1235 / `makeFolderRow` L1237-1261); the view drills client-side over `GET /api/docs/tree` (phase 97/99 — pure builder `build_kb_tree` `app/api/docs.py` L377-490 over `TreeDocRow` tuples L296-302; file nodes carry `indexed_at` verbatim, folder nodes carry recursive `documents` counts); the document viewer (click → same-page modal, phase 26; full-page escape `/document.html`) renders through ONE shared core `renderDocument` (`frontend/assets/document.js` L118-176: title + the `.doc-meta` badge row — `source · format · path · Indexed · chunks` — then content; `fmtDate` L85, `metaBadge` L97); the admin-only inline editor idiom is phase 57's summary editor (`docAdminReady()` gate, `wireSummaryEdit`, `PATCH /api/documents/summary` in `app/api/docs.py` L164-232 — embed-before-write, 404 unknown pair, admin gate). House patterns: Alembic head is `0019` (every migration ships a tested downgrade, A13); env settings are `BOR_`-prefixed `Settings` fields with the kill-switch validator pattern (`agent_max_rounds`: 0 disables, negative fails startup loudly, `app/config.py` L155-168); the `manually_edited` flag (`folder_summaries`, phase 97) is the precedent for owner corrections surviving the sync-time generator; integration tests seed real Postgres with deterministic axis vectors (`tests/integration/test_name_hit_lexical.py` — `D=768` unit vectors, exact cosines); E2E story suites seed through the REAL importer against a fixture dir with the deterministic mock LLM (`tests/e2e/test_retrieval_quality.py` — `_import_fixtures`/`_reset_db`/source-chip assertions); `tests/conftest.py` holds the `db` fixture; `tests/fakes.py` the fake LLM. ## Objective Every indexed document carries a **creation date** from its source — the git last-commit date for git sources (true per-file for local-path checkouts, the repo tip-commit date for shallow URL checkouts), the file mtime for local directories and unpacked uploads (the unpackers now preserve member mtimes) — normalized (undetermined or future → today), stored NOT-NULL on `documents.created_at` (existing rows backfilled to the migration day per the owner), refreshed on every sync (allowed to go older), and editable by the admin (the correction survives syncs via a manual flag). The date is fed to the LLM on every document surface (the HIGH prompt's `` block, the `read` result, the `ls` file line), shown in the UI at the top of a clicked document (viewer badge) and in both catalog tables (files: `Created` before `Indexed`; folders/sources: `Updated` after `Documents`, before `Description` — the subtree's max document date), and applied in retrieval as a small, env-tunable, kill-switchable recency boost on the RRF-fused score that NEVER lets a newer similar document outrank an older document that actually answers the question (pinned by a dedicated integration battery + the story E2E). ## Dependencies - `105_hidden_folders_toggle` (complete) — the per-root `str(root)` map idiom in the importer both entry points already build (task 04 adds the third map alongside); its suites must stay green. - `09_story_retrieval_quality` (complete) — the A7 hybrid retriever, `fuse()`/`select_documents`, and the eval script the boost extends; the fixture-KB E2E pattern. - `97_kb_tree_catalog` / `99_kb_tree_table_and_back_nav` (complete) — the pure tree builder + the RAG view tables/rows this phase's columns extend; the ls↔tree cross-check that a changed `ls` line must keep. - `57_edit_document_summaries` (complete) — the admin-only inline editor + `PATCH /api/documents/…` idiom the date editor copies. - `28_git_based_sources` (complete) — `scripts/git_sync.py` (the ONLY git-invocation site, A11) where the per-file date walk is added; `--depth 1` stays (D10). - `90_upload_no_scan` / phase 49 uploads (complete) — the unpacker this phase makes mtime-preserving (task 03); the scan-deferred upload flow itself is untouched. ## Design (shared by all tasks — the executor reads this, not the chat) - **Storage (task 01, D1).** `documents.created_at` — `DateTime(timezone=True)` **NOT NULL**, `server_default=func.now()` — so every row of an existing deployment backfills to the migration moment (≈ today, the owner's instruction) and the next sync replaces it with the real sourced date (D4 makes the unchanged path write). `documents.created_at_manual` — `Boolean` NOT NULL, Python `default=False`, `server_default=text("false")` (the `include_hidden` L159-167 / `manually_edited` phase-97 column style). Alembic `0020_documents_created_at.py` revises `0019`; downgrade drops BOTH columns (A13). - **Normalization (task 02, D3).** New module `app/rag/doc_dates.py` — pure, stdlib-only, the single choke point every date passes through: ```python FUTURE_SKEW_TOLERANCE = timedelta(days=1) def normalize_doc_date(raw: datetime | None, now: datetime | None = None) -> datetime ``` `None` (undetermined) → `now` (default `datetime.now(UTC)`, date-part-irrelevant — full precision kept); naive `raw` → treated as UTC; aware `raw` → converted to UTC; `raw > now + FUTURE_SKEW_TOLERANCE` (a genuinely FUTURE date — the 1-day tolerance absorbs clock skew, pinned) → `now`. Also `file_mtime_datetime(path: Path) -> datetime` = `datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)` (epoch mtimes are tz-agnostic — UTC is the correct rendering). No other module re-derives these rules. - **Source date extraction (task 03, D2/D10).** - **Archives** — `_unpack_zip`: after each regular-file write, `os.utime(dest, ns=(t, t))` from the member's DOS `date_time` (`datetime(*member.date_time, tzinfo=timezone.utc).timestamp()` — DOS mtimes are tz-agnostic like epochs); `_unpack_tar`: `os.utime(dest, ns=(member.mtime, member.mtime))`. Regular files ONLY (dirs/symlinks/hardlinks untouched — only regular files are ever indexed); the zip-bomb cap and every safety check are UNCHANGED (the utime calls come after `_write_capped` succeeds — a failed unpack still removes the partial tree). - **Git** — `scripts/git_sync.py` gains `file_commit_dates(dest: Path) -> dict[str, datetime]`: ONE `git log --name-only --format=@@%cI` walk through `run_git` (the A11 single-invocation site), parsed newest-first — per repo-relative POSIX path, the FIRST sighting wins (the latest commit that touched it); paths normalized to POSIX (`/`); ISO-strict parsed via `datetime.fromisoformat`. **Fail-soft:** any `GitSyncError`/parse failure → `{}` (logged) — the importer falls back to file mtimes; a date walk must never break a sync. Verified behavior: local-path checkouts (full history) → true per-file dates; shallow URL checkouts → the tip commit's date for every working-tree file (the shallow-boundary property, pinned by an integration test using a `file://` clone). - **Importer semantics (task 04, D4).** `import_sources(…, doc_dates_by_root: dict[str, dict[str, datetime]] | None = None)` — keyed by `str(root)` exactly like `ignore_by_root`/`include_hidden_by_root`; ONLY git roots are listed (the entry points build the map from `file_commit_dates` after the clone); unlisted roots (local dirs, uploads) take the mtime fallback. In `_index_file` (receives `raw_date: datetime | None` pre-resolved by the loop — `map.get(rel)` else `None`): - `raw_date is None` → `file_mtime_datetime(full_path)` (one `stat`). - **added / updated (content change)** → `doc.created_at = normalize_doc_date(raw)`, `doc.created_at_manual = False` — a new content version is a new document date; a previous manual correction referred to the old content and is deliberately reset. - **unchanged (hash match)** → if `doc.created_at_manual` → leave the row entirely (the owner's correction survives syncs — the phase-97 `manually_edited` precedent); else recompute `normalize_doc_date(raw)` and, when it differs from the stored value, write + commit it (a date-only refresh) and count it in the NEW `ImportSummary.dates_updated` (additive field + the `import: summary … dates_updated=N` log term, PLAN §9). A date-only refresh is still `unchanged`: it NEVER counts toward `added/updated/pruned` → no `sources_meta` bump, no overview/folder-summary regeneration, no saved-chat staleness (D4 — content is the KB, the date is annotation). - Entry points: `_run_sync` — in the existing per-row loop, for `kind=git` rows after `clone_or_pull`: `doc_dates_by_root[str(root)] = file_commit_dates(root)`; pass the map to `import_sources`; add `"dates_updated": summary.dates_updated` to the success `detail` (additive key). `scripts/import_docs.py::_resolve_sources` returns the map as a 4th tuple element (manual `--source` dirs and env-fallback rows contribute nothing); `main` passes it through (L331-334). - **API surface (task 05, D7/D8/D9).** - `GET /api/docs` — `DocSummary` gains `created_at: str` (ISO-8601, `doc.created_at.isoformat()`) — the column joins the existing select + group_by (`indexed_at` L104/L107). `GET /api/documents/content` — `DocContent` gains `created_at: str` (L161 site). - `PATCH /api/documents/date` (NEW, admin-only, `require_admin` — the `update_document_summary` gate L164-232): body `DateUpdate {source: str, path: str, date: str | None}`. Unknown pair → 404 `document not found` (row-lookup semantics, no filesystem — the `/documents/content` rule). `date` present → `datetime.fromisoformat` (a bare `YYYY-MM-DD` or full ISO datetime; a malformed value → the 422 the model gives) → `normalize_doc_date` (D3 — a manually set future date also folds to today; consistency with the sourced path) → `created_at` set + `created_at_manual = True`. `date` null/absent → the CLEAR: `created_at_manual = False` only (the stored date stands until the next sync refreshes it — the API is DB-only and cannot re-read the source). Response `DateResult {source, path, created_at: str, created_at_manual: bool}` echoing the stored state. No LLM/embedding call (a date is never embedded — the phase-57 no-LLM contrast). - `GET /api/docs/tree` — `TreeDocRow` becomes a 6-tuple `(source, path, title, chunks, indexed_at, created_at_iso)`; the endpoint's query adds `Document.created_at` (the `indexed_at` select/group_by site L543-546); `KbTreeFile` gains `created_at: str`; `KbTreeFolder`/`KbTreeSource` gain `updated_at: str | None` (D9: the subtree's MAX `created_at`, computed in the PURE builder as it recurses — `None` for a 0-document source, the `summary`-null shape); the builder threads the max through `_source_node` → `_level_children` (folders: the max of own files + child subfolders). No new table, no new column beyond task 01's (D9). - **LLM surfaces (task 06, D5).** - Retriever plumbing: `_LEXICAL_SQL` + `_NAME_HIT_SQL` gain `d.created_at AS created_at`; the two detached `Document(…)` reconstructions (L360-372, L414-424) pass `created_at=row.created_at`. The vector path is ORM rows — nothing to do. - HIGH prompt (`app/rag/prompts.py` L371-376): the block becomes `` — the UTC date part, attribute appended AFTER `title` (the only position; the attribute is always present — `created_at` is NOT NULL). No persona/teaching copy changes (phase 03 convention — the date rides existing locked text). - `read` tool (`app/rag/agent.py` L1173-1185): the first line stays `Document {source}/{path}:` BYTE-IDENTICAL (the mock's `_READ_RESULT_PREFIX` header contract — `_read_results` strips exactly that header to recover the path), the date is the SECOND line: `Document {sp}:\ndate: {doc.created_at:%Y-%m-%d}\n{content…}` — both the truncated (L1176) and the plain (L1183) results. - `ls` file line: APPEND ` | date: {YYYY-MM-DD}` at the END — `source: X | path: Y | title: Z | date: 2024-06-15` (the mock's `_CATALOG_LINE_RE` `title: .+$` still matches — the appended field lands inside the greedy tail; do NOT insert before `title`, where the non-greedy `path` capture would swallow it). Plumbing: `_source_document_rows` returns `(path, title, created_iso_date)`; `group_folder_listing` rows become `Sequence[tuple[str, str, str]]` and its file triples become `(source, path, title, date)`; `render_folder_listing` renders the appended field; the NOT-A-FOLDER refusal path (L1121-1130) passes through unchanged in shape; the `app/api/docs.py` ls↔tree cross-check docstrings + `tests/unit/test_kb_tree_builder.py` cross-check tests compare the extended tuples (the "UI shows what the agent sees" invariant, extended with the date). - `tests/e2e/mock_llm.py` `_DOCUMENT_BLOCK_RE` (L785-789): make the date attribute an OPTIONAL group — `r'title="[^"]*"(\sdate="[^"]*")?>'` — so the mock tolerates pre- and post-phase shapes (house rule: marker/regex changes land with the prompt change, in this task). - **Recency boost (task 07, D6).** `Settings` gains (the hybrid block, `app/config.py` L163-174): `recency_boost: float = 0.001` (the max additive score for a zero-age document; **`0` = off — pre-phase ranking byte-identical**, the kill switch; negative → startup failure naming the field, the `agent_max_rounds` validator pattern) and `recency_half_life_days: int = 365` (`<= 0` fails startup). `.env.example` documents `BOR_RECENCY_BOOST` / `BOR_RECENCY_HALF_LIFE_DAYS`. `app/rag/retriever.py` gains the pure `apply_recency_boost(chunks, *, now=None, weight=None, half_life_days=None) -> list[RetrievedChunk]`: for each chunk, `age_days = max(0.0, (now − doc.created_at).total_seconds() / 86400.0)` (future clamps to 0 — a future-sourced doc reads as brand-new, consistent with D3), `score += weight * math.exp(−age_days / half_life_days)` (defaults from `get_settings()` when args omitted), then re-sorted with the EXISTING deterministic key `(−score, −cosine, document.path, position)` — with `weight=0` the scores and order are untouched (pinned). `retrieve()` (L398-425) applies it AFTER `fuse()` when `settings.recency_boost > 0` (the single apply site — chat API and `scripts/eval_retrieval.py` get it automatically; the script's printed table gains the document date + the post-boost effective score column). UNTOUCHED by design: the A8 honesty gate + `query_log.top_score` (cosine), `weak_hit_titles` (titles only), the never-truncated top-N contract, `fuse()` itself. The boost re-ranks which ≤N documents are FED (the whole point) but never truncates. - **The fine line (task 07's battery — the owner's warning, pinned permanently).** Real-Postgres integration tests with axis vectors (the `test_name_hit_lexical.py` idiom — exact cosines, deterministic): 1. **Old-correct beats new-similar (THE owner scenario):** doc A (created 2020-01-01) answers the question; doc B (created yesterday) is topically similar (shares tokens, weaker answer). Under DEFAULTS A must be `select_documents()[0]`; ALSO under `recency_boost=0` (no-regression pin: relevance alone already ordered them). 2. **Boost is real:** two near-twin documents (identical or 1-rank-apart fused scores, engineered so B's advantage is < the zero-age boost) — with defaults the NEWER ranks first; with `weight=0` the OLDER ranks first (proving the boost is the differentiator, not drift). 3. **Decay:** the boost halves at the half-life (pure-function unit pins: age 0 → full `weight`; age = half-life → `weight/e`… asserted as `weight * exp(-1)`; age ≫ → ~0; future → full; `weight=0` → byte-identical list order). 4. If the defaults (0.001 / 365 d) fail test 1's margin, tune the DEFAULTS (not the test) until old-correct wins with ≥ a comfortable fused-score margin, and record the measured margin in the test docstring — the owner re-tunes live via `BOR_RECENCY_BOOST`. - **UI (tasks 08-09, D7/D8).** - `frontend/index.html`: the file table header gains `Created` between `Chunks` and `Indexed` (L487-491); the folder table header gains `Updated` between `Documents` and `Description` (L473-475). No other shell change. - `frontend/assets/sources.js`: `makeRow` — insert the `Created` cell BEFORE the `Indexed` one (the L1401 value list becomes `[title, chunks, fmtDate(d.created_at), fmtDate(d.indexed_at)]`; the row object fed from tree file nodes (L1341-1350) carries `created_at`); `makeSourceRow`/`makeFolderRow` — one `td` with `fmtDate(node.updated_at)` (or `"–"` when null — the `statLast` null idiom L1294) between the count `td` and the description cell. All `textContent` (the XSS contract — never innerHTML with document-derived data). - `frontend/assets/document.js` (the ONE shared core — modal AND `/document.html` page): the `.doc-meta` badge row gains `metaBadge("doc-created", \`Created ${fmtDate(doc.created_at)}\`)` INSERTED BEFORE the `Indexed` badge (L127) — the date at the top of a clicked document (D8). The badge class reuses the `doc-indexed` styling family (a `doc-created` rule in `styles.css` next to it, provenance comment citing phase 106; contrast ≥4.5:1 verified + recorded in the comment, house style). - Task 09 — the admin date editor (the phase-57 `wireSummaryEdit` idiom verbatim, gated by `docAdminReady()` — anonymous/token holders see the byte-identical phase-36/106 badge row, no button, no network call): an **`Edit date`** text button after the Created badge opens an inline editor in the meta row — a native `` (prefilled `doc.created_at`'s UTC date part, `aria-label` "Document creation date") + Save/Cancel buttons + a `role="status"` live line; Save → `PATCH /api/documents/date {source, path, date: }` — the endpoint is `require_admin` (the phase-57 split: the viewer content is `require_user`-gated, but the EDIT is admin-only; the editor is wired only for the admin, exactly as `wireSummaryEdit`); 200 → the badge re-renders from the response's `created_at`, `announcer` confirmation, status clears (§7.4); 4xx/5xx/network → the error line (`role="alert"`), the editor reverts to the stored value, controls re-enabled — the UI never claims a state the server didn't save. A **clear** affordance (the phase-57 "clear = empty" contrast): a "Revert to sync" link in the editor that sends `{source, path, date: null}` (D7's CLEAR — the manual flag drops; the date shows until the next sync). - **NOT touched:** `indexed_at` (everywhere), the A7 fusion math / A8 gate / never-truncated contract, `sources_meta` staleness semantics, the clone strategy (`--depth 1`, D10), the upload scan-deferral (phase 90), suggestions, shared chats, the stat cards, `AGENTS.md`, `.agents/PLAN.md`, any completed phase. The `ls` top-level SOURCE lines and folder lines carry NO date (only FILE lines do — files are documents). ## Tasks 1. `01_created_at_column.md` — `documents.created_at` + `documents.created_at_manual` (model + alembic `0020`) + default/round-trip/migration tests. 2. `02_date_normalization.md` — `app/rag/doc_dates.py` (`normalize_doc_date` + `file_mtime_datetime`) + boundary unit tests. 3. `03_source_date_extraction.md` — mtime-preserving zip/tar unpack + `scripts/git_sync.py::file_commit_dates` (local full-history vs shallow tip-date, fail-soft) + tests. 4. `04_importer_dates.md` — `import_sources` date map + the added/updated/unchanged semantics + `dates_updated` + both entry-point wirings + unit & integration tests. 5. `05_date_apis.md` — `created_at` on `/api/docs` + `/api/documents/content`, the admin `PATCH /api/documents/date`, and the tree's `created_at`/`updated_at` (schemas + pure builder + endpoint) + tests. 6. `06_llm_date_surfaces.md` — retriever `created_at` plumbing, the `` block, the `read` date line, the `ls` appended date field, the mock-LLM regex, pin updates + tests. 7. `07_recency_boost.md` — settings + validators, `apply_recency_boost` in `retrieve()`, `eval_retrieval` columns, `.env.example`, the fine-line integration battery + unit tests. 8. `08_ui_dates.md` — the `Created` file column, the `Updated` folder/source column, the viewer `Created` badge (+ CSS/a11y) + source-level unit pins. 9. `09_date_editor.md` — the admin-only inline date editor in the viewer (PATCH wiring, the clear/revert affordance, §7.4 lifecycle, a11y) + unit pins. 10. `10_e2e_document_dates.md` — dedicated Playwright suite `tests/e2e/test_document_dates.py` (isolation) + regressions + full gate + atomic commit. ## Testing & Quality - Unit — `tests/unit/test_doc_dates.py` (task 02, the boundary matrix: None→today; naive-as-UTC; aware conversion; future just inside the 1-day tolerance keeps its date; future beyond → today; the mtime helper); `tests/unit/test_archive_upload_dates.py` (task 03 — a zip with an explicit `date_time` and a tar with an explicit `mtime` unpack to files carrying those mtimes; the cap/safety suite stays green); the `file_commit_dates` pins (task 03 — a scratch repo: local clone → per-file dates; a `file://` shallow clone → tip date for every file; a missing `.git`/git failure → `{}` fail-soft); `tests/unit/test_importer_dates.py` (task 04 — a fake-LLM + tmp-tree: utime'd files land as `created_at`; the unchanged-refresh write + counter; the manual-flag skip; the content-change reset; `doc_dates_by_root` map hit vs mtime fallback); `tests/unit/test_kb_tree_builder.py` (extended, task 05 — file `created_at` verbatim, folder/source `updated_at` = subtree max, `None` for 0-doc sources, the extended cross-check tuples); `tests/unit/test_prompts_dates.py` (task 06 — the block attribute's position/format, the read line shape, the ls line's appended field, the weight-0 byte-identity of the boosted order lives in task 07's suite); `tests/unit/test_retriever_recency.py` (task 07 — the decay/weight/tie pins); `tests/unit/test_sources_dates.py` (tasks 08/09 — the house read-the-assets-as-text pattern: header cell ORDER pinned in both tables, the row cell order, the badge order in the shared core, the editor wiring + its aria labels + the single-source cross-file check that the editor PATCHes `/api/documents/date`). - Integration — `tests/integration/test_migration_0020.py` (task 01, the house migration-suite pattern: upgrade from 0019 adds the columns with the server defaults — a pre-existing row reads `created_at ≈ now()` and `created_at_manual is False`; downgrade restores 0019's schema); `tests/integration/test_git_file_dates.py` (task 03 — real `git` scratch repos through `clone_or_pull` + `file_commit_dates`, both checkout kinds); `tests/integration/test_importer_dates.py` (task 04 — real Postgres: a backfilled-today row refreshed to its old utime on an unchanged re-import; the manual row untouched; the `dates_updated` counter; the date-only refresh does NOT bump `sources_meta`); `tests/integration/test_docs_api_dates.py` (task 05 — `created_at` in both reads; the PATCH matrix: set (round-trip + flag), malformed 422, null-clear (flag drops, date stands), 404 unknown pair, anonymous 403; the tree's `updated_at` on a nested fixture); `tests/integration/test_agent_tools_dates.py` (task 06 — the `ls` line + `read` result carry the date against real rows); `tests/integration/test_recency_boost.py` (task 07 — the fine-line battery, the owner's scenario, real Postgres + axis vectors); `tests/e2e/test_document_dates.py` (task 10, A16, isolation). - E2E (mandatory, A16) — `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` with the DB up. - Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing` — the validate.sh gate; every new/modified module is fully covered by the suites above). ## Completion Criteria - [ ] Every `documents` row has a non-null `created_at` (fresh imports carry the sourced date — git last-commit or mtime; existing deployments' rows read the migration day until the first sync refreshes them); the `0020` migration upgrades from `0019` and downgrades cleanly on the dev/test DB. - [ ] A sync (button OR CLI) refreshes dates: an unchanged file whose mtime moved gets the new date (`dates_updated` counts it); a file's date may go OLDER; a manually corrected date (`created_at_manual`) survives the sync while its siblings refresh; a content change resets the date and clears the manual flag; a date-only refresh leaves `sources_meta.version`, the overview, and the folder summaries untouched. - [ ] A zip and a tar with old member timestamps unpack to files carrying those mtimes (the upload path's dates survive the archive process); a file with a future (beyond-skew) or missing date lands as today. - [ ] The LLM sees the date on every document surface: the HIGH prompt's `` block (mock-LLM regex updated, tolerant), the `read` result's `date:` second line (first line byte-identical), and the `ls` file line's appended ` | date: YYYY-MM-DD` — with the ls↔tree cross-check still holding. - [ ] The UI shows the date everywhere asked: the file table's `Created` column before `Indexed`; the folder/source table's `Updated` column after `Documents`, before `Description` (subtree max, `–` for empty); the clicked document's top meta row carries the `Created ` badge in BOTH the modal and the full page. - [ ] An admin edits a document's date in the viewer (set + revert-to-sync), the change round-trips through `PATCH /api/documents/date`, and the next sync preserves the correction while refreshing the rest; non-admins see the byte-identical badge row with no editor; the failure path reverts + announces. - [ ] Retrieval: the owner's scenario is pinned — an older document that answers the question ranks above a newer similar one that doesn't (defaults AND with the boost off); near-ties break toward the newer document (boost on) and toward the pre-phase order (boost off); `BOR_RECENCY_BOOST=0` restores byte-identical ranking. - [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` green in isolation (DB up); the regression suites listed in task 10 green in isolation; `uv run ruff check . && uv run pyright` clean. - [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate. ## Locked decisions - **D1 — NOT-NULL storage + manual flag (owner-instructed 2026-09-13; flag = the phase-97 `manually_edited` precedent).** `documents.created_at` NOT NULL, server-defaulted to `now()` at migration time (existing deployments get "today", corrected on the next sync — the owner's exact words); `documents.created_at_manual` (default false) protects an owner-corrected date from sync refreshes — owner corrections are never silently rewritten (the phase-97 decision, applied to the document date). - **D2 — Date provenance (owner-instructed).** Git sources: the per-file LAST-COMMIT date (`git log --name-only --format=@@%cI`, committer date, one git call per source per sync, fail-soft to mtime). Everything else (local dirs, unpacked uploads): the file mtime — with the unpackers fixed to PRESERVE member mtimes (the owner's "hopefully" made real). - **D3 — Normalization (owner-instructed).** Undetermined → today; future (beyond a 1-day clock-skew tolerance) → today; naive → UTC; all stored as UTC `timestamptz` at full precision (the UI formats). One choke point (`app/rag/doc_dates.py`). - **D4 — Sync semantics (owner-instructed).** The date refreshes on every sync, including unchanged files, and may go OLDER (no monotonic guard). Added/updated content resets the date + clears the manual flag. A date-only refresh is `unchanged` for every existing gate (no `sources_meta` bump, no overview/summary regeneration, no staleness) and is counted in a NEW additive `dates_updated` counter (log line + sync detail). - **D5 — LLM surfaces (owner-instructed: "all documents must include the date when fed to the LLM").** The `date="YYYY-MM-DD"` attribute appended to the `` block (after `title`); the `read` result's second line `date: YYYY-MM-DD` (first line byte-identical — the mock header contract); the `ls` FILE line's appended ` | date: YYYY-MM-DD` (appended, never inserted — the mock line regex). No persona/teaching copy changes. - **D6 — The recency boost (owner-instructed: "ranked higher… or at least given a boost, without breaking the existing retrieval process").** An additive post-fusion term on the RRF score — `fused + recency_boost·exp(−age_days/recency_half_life_days)` — defaults `0.001` / `365` days, env-tunable (`BOR_RECENCY_BOOST`, `BOR_RECENCY_HALF_LIFE_DAYS`), `0` = byte-identical off, negatives fail startup loudly (the house validator pattern). Applied once in `retrieve()` after `fuse()`. The A7 math, the A8 cosine gate, `query_log.top_score`, and the never-truncated contract are untouched. The owner's old-correct-beats-new-similar scenario is a permanent integration pin. - **D7 — Editing (owner-instructed: "editable so users can correct for errors").** Admin-only `PATCH /api/documents/date` (the phase-57 split: the viewer stays user-gated, the edit is admin-gated); a date string sets + flags manual; null clears the flag (the date stands until the next sync). The editor lives in the shared viewer core (modal + page), phase-57 idiom, §7.4 never-stale lifecycle. - **D8 — UI columns/badge (owner-instructed positions, verbatim).** Files: `Created` between `Chunks` and `Indexed`. Folders/sources: `Updated` between `Documents` and `Description`. Clicked document: the `Created` badge in the top meta row, before `Indexed`. - **D9 — Folder dates are derived.** No folder date storage: `updated_at` = the subtree's max document `created_at`, computed in the pure tree builder (one concept end to end — the same recursion that counts `documents`); `None` for an empty source. - **D10 — No clone-strategy change (phase 28 stands).** `--depth 1` stays. Consequence (verified 2026-09-13): local-path git sources clone with FULL history → true per-file dates; URL git sources are shallow → every file carries the repo's TIP-commit date (uniform within the repo — no intra-repo distortion, a real cross-source signal, refreshed on every pull). Revisit only if the owner later wants intra-repo recency on URL sources. **A7 note (PLAN anchor, owner-permitted extension 2026-09-13):** the recency boost is an additive POST-fusion re-ranking on A7's fused score, requested by the owner in this phase's ask ("newer documents should be ranked higher in retrieval… without breaking the existing retrieval process"). A7's hybrid mechanics, windows, k, and never-truncated contract are unchanged; when the owner signs off, `PLAN.md` §2's A7 row gains a dated revision note (the skill that authors phases never edits `PLAN.md`). ## Commit ```bash git add app/ alembic/versions/0020_documents_created_at.py scripts/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(dates): document dates end to end — sourced at sync, shown in UI, editable, recency-weighted in retrieval" ```