Everything is verified green. Final report: **Phase 106 — Document dates (final verification pass; all 10 tasks already complete)** - Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed. - `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓) - `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up) - 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation** - `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings** **Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions. - **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring). - **Next pending phase:** none — `todo/` holds only this phase.
36 KiB
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 -- <file> 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 <document> 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 <date> · 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 <document> 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-rootstr(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 changedlsline 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 1stays (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—BooleanNOT NULL, Pythondefault=False,server_default=text("false")(theinclude_hiddenL159-167 /manually_editedphase-97 column style). Alembic0020_documents_created_at.pyrevises0019; 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:FUTURE_SKEW_TOLERANCE = timedelta(days=1) def normalize_doc_date(raw: datetime | None, now: datetime | None = None) -> datetimeNone(undetermined) →now(defaultdatetime.now(UTC), date-part-irrelevant — full precision kept); naiveraw→ treated as UTC; awareraw→ converted to UTC;raw > now + FUTURE_SKEW_TOLERANCE(a genuinely FUTURE date — the 1-day tolerance absorbs clock skew, pinned) →now. Alsofile_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 DOSdate_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_cappedsucceeds — a failed unpack still removes the partial tree). - Git —
scripts/git_sync.pygainsfile_commit_dates(dest: Path) -> dict[str, datetime]: ONEgit log --name-only --format=@@%cIwalk throughrun_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 viadatetime.fromisoformat. Fail-soft: anyGitSyncError/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 afile://clone).
- Archives —
- Importer semantics (task 04, D4).
import_sources(…, doc_dates_by_root: dict[str, dict[str, datetime]] | None = None)— keyed bystr(root)exactly likeignore_by_root/include_hidden_by_root; ONLY git roots are listed (the entry points build the map fromfile_commit_datesafter the clone); unlisted roots (local dirs, uploads) take the mtime fallback. In_index_file(receivesraw_date: datetime | Nonepre-resolved by the loop —map.get(rel)elseNone):raw_date is None→file_mtime_datetime(full_path)(onestat).- 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-97manually_editedprecedent); else recomputenormalize_doc_date(raw)and, when it differs from the stored value, write + commit it (a date-only refresh) and count it in the NEWImportSummary.dates_updated(additive field + theimport: summary … dates_updated=Nlog term, PLAN §9). A date-only refresh is stillunchanged: it NEVER counts towardadded/updated/pruned→ nosources_metabump, 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, forkind=gitrows afterclone_or_pull:doc_dates_by_root[str(root)] = file_commit_dates(root); pass the map toimport_sources; add"dates_updated": summary.dates_updatedto the successdetail(additive key).scripts/import_docs.py::_resolve_sourcesreturns the map as a 4th tuple element (manual--sourcedirs and env-fallback rows contribute nothing);mainpasses it through (L331-334).
- API surface (task 05, D7/D8/D9).
GET /api/docs—DocSummarygainscreated_at: str(ISO-8601,doc.created_at.isoformat()) — the column joins the existing select + group_by (indexed_atL104/L107).GET /api/documents/content—DocContentgainscreated_at: str(L161 site).PATCH /api/documents/date(NEW, admin-only,require_admin— theupdate_document_summarygate L164-232): bodyDateUpdate {source: str, path: str, date: str | None}. Unknown pair → 404document not found(row-lookup semantics, no filesystem — the/documents/contentrule).datepresent →datetime.fromisoformat(a bareYYYY-MM-DDor 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_atset +created_at_manual = True.datenull/absent → the CLEAR:created_at_manual = Falseonly (the stored date stands until the next sync refreshes it — the API is DB-only and cannot re-read the source). ResponseDateResult {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—TreeDocRowbecomes a 6-tuple(source, path, title, chunks, indexed_at, created_at_iso); the endpoint's query addsDocument.created_at(theindexed_atselect/group_by site L543-546);KbTreeFilegainscreated_at: str;KbTreeFolder/KbTreeSourcegainupdated_at: str | None(D9: the subtree's MAXcreated_at, computed in the PURE builder as it recurses —Nonefor a 0-document source, thesummary-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_SQLgaind.created_at AS created_at; the two detachedDocument(…)reconstructions (L360-372, L414-424) passcreated_at=row.created_at. The vector path is ORM rows — nothing to do. - HIGH prompt (
app/rag/prompts.pyL371-376): the block becomes<document source="{doc.source}" path="{doc.path}" title="{doc.title}" date="{doc.created_at:%Y-%m-%d}">— the UTC date part, attribute appended AFTERtitle(the only position; the attribute is always present —created_atis NOT NULL). No persona/teaching copy changes (phase 03 convention — the date rides existing locked text). readtool (app/rag/agent.pyL1173-1185): the first line staysDocument {source}/{path}:BYTE-IDENTICAL (the mock's_READ_RESULT_PREFIXheader contract —_read_resultsstrips 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.lsfile line: APPEND| date: {YYYY-MM-DD}at the END —source: X | path: Y | title: Z | date: 2024-06-15(the mock's_CATALOG_LINE_REtitle: .+$still matches — the appended field lands inside the greedy tail; do NOT insert beforetitle, where the non-greedypathcapture would swallow it). Plumbing:_source_document_rowsreturns(path, title, created_iso_date);group_folder_listingrows becomeSequence[tuple[str, str, str]]and its file triples become(source, path, title, date);render_folder_listingrenders the appended field; the NOT-A-FOLDER refusal path (L1121-1130) passes through unchanged in shape; theapp/api/docs.pyls↔tree cross-check docstrings +tests/unit/test_kb_tree_builder.pycross-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).
- Retriever plumbing:
- Recency boost (task 07, D6).
Settingsgains (the hybrid block,app/config.pyL163-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, theagent_max_roundsvalidator pattern) andrecency_half_life_days: int = 365(<= 0fails startup)..env.exampledocumentsBOR_RECENCY_BOOST/BOR_RECENCY_HALF_LIFE_DAYS.app/rag/retriever.pygains the pureapply_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 fromget_settings()when args omitted), then re-sorted with the EXISTING deterministic key(−score, −cosine, document.path, position)— withweight=0the scores and order are untouched (pinned).retrieve()(L398-425) applies it AFTERfuse()whensettings.recency_boost > 0(the single apply site — chat API andscripts/eval_retrieval.pyget 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.pyidiom — exact cosines, deterministic):- 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 underrecency_boost=0(no-regression pin: relevance alone already ordered them). - 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=0the OLDER ranks first (proving the boost is the differentiator, not drift). - Decay: the boost halves at the half-life (pure-function unit pins: age 0 → full
weight; age = half-life →weight/e… asserted asweight * exp(-1); age ≫ → ~0; future → full;weight=0→ byte-identical list order). - 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.
- 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
- UI (tasks 08-09, D7/D8).
frontend/index.html: the file table header gains<th scope="col">Created</th>betweenChunksandIndexed(L487-491); the folder table header gains<th scope="col">Updated</th>betweenDocumentsandDescription(L473-475). No other shell change.frontend/assets/sources.js:makeRow— insert theCreatedcell BEFORE theIndexedone (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) carriescreated_at);makeSourceRow/makeFolderRow— onetdwithfmtDate(node.updated_at)(or"–"when null — thestatLastnull idiom L1294) between the counttdand the description cell. AlltextContent(the XSS contract — never innerHTML with document-derived data).frontend/assets/document.js(the ONE shared core — modal AND/document.htmlpage): the.doc-metabadge row gainsmetaBadge("doc-created", \Created ${fmtDate(doc.created_at)}`)INSERTED BEFORE theIndexedbadge (L127) — the date at the top of a clicked document (D8). The badge class reuses thedoc-indexedstyling family (adoc-createdrule instyles.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
wireSummaryEditidiom verbatim, gated bydocAdminReady()— anonymous/token holders see the byte-identical phase-36/106 badge row, no button, no network call): anEdit datetext button after the Created badge opens an inline editor in the meta row — a native<input type="date">(prefilleddoc.created_at's UTC date part,aria-label"Document creation date") + Save/Cancel buttons + arole="status"live line; Save →PATCH /api/documents/date {source, path, date: <input value>}— the endpoint isrequire_admin(the phase-57 split: the viewer content isrequire_user-gated, but the EDIT is admin-only; the editor is wired only for the admin, exactly aswireSummaryEdit); 200 → the badge re-renders from the response'screated_at,announcerconfirmation, 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_metastaleness 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. Thelstop-level SOURCE lines and folder lines carry NO date (only FILE lines do — files are documents).
Tasks
01_created_at_column.md—documents.created_at+documents.created_at_manual(model + alembic0020) + default/round-trip/migration tests.02_date_normalization.md—app/rag/doc_dates.py(normalize_doc_date+file_mtime_datetime) + boundary unit tests.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.04_importer_dates.md—import_sourcesdate map + the added/updated/unchanged semantics +dates_updated+ both entry-point wirings + unit & integration tests.05_date_apis.md—created_aton/api/docs+/api/documents/content, the adminPATCH /api/documents/date, and the tree'screated_at/updated_at(schemas + pure builder + endpoint) + tests.06_llm_date_surfaces.md— retrievercreated_atplumbing, the<document date=…>block, thereaddate line, thelsappended date field, the mock-LLM regex, pin updates + tests.07_recency_boost.md— settings + validators,apply_recency_boostinretrieve(),eval_retrievalcolumns,.env.example, the fine-line integration battery + unit tests.08_ui_dates.md— theCreatedfile column, theUpdatedfolder/source column, the viewerCreatedbadge (+ CSS/a11y) + source-level unit pins.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_e2e_document_dates.md— dedicated Playwright suitetests/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 explicitdate_timeand a tar with an explicitmtimeunpack to files carrying those mtimes; the cap/safety suite stays green); thefile_commit_datespins (task 03 — a scratch repo: local clone → per-file dates; afile://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 ascreated_at; the unchanged-refresh write + counter; the manual-flag skip; the content-change reset;doc_dates_by_rootmap hit vs mtime fallback);tests/unit/test_kb_tree_builder.py(extended, task 05 — filecreated_atverbatim, folder/sourceupdated_at= subtree max,Nonefor 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 readscreated_at ≈ now()andcreated_at_manual is False; downgrade restores 0019's schema);tests/integration/test_git_file_dates.py(task 03 — realgitscratch repos throughclone_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; thedates_updatedcounter; the date-only refresh does NOT bumpsources_meta);tests/integration/test_docs_api_dates.py(task 05 —created_atin both reads; the PATCH matrix: set (round-trip + flag), malformed 422, null-clear (flag drops, date stands), 404 unknown pair, anonymous 403; the tree'supdated_aton a nested fixture);tests/integration/test_agent_tools_dates.py(task 06 — thelsline +readresult 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-covwith 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
documentsrow has a non-nullcreated_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); the0020migration upgrades from0019and 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_updatedcounts 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 leavessources_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
<document … date="YYYY-MM-DD">block (mock-LLM regex updated, tolerant), thereadresult'sdate:second line (first line byte-identical), and thelsfile 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
Createdcolumn beforeIndexed; the folder/source table'sUpdatedcolumn afterDocuments, beforeDescription(subtree max,–for empty); the clicked document's top meta row carries theCreated <date>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=0restores byte-identical ranking. uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run pytest tests/e2e/test_document_dates.py -v --no-covgreen in isolation (DB up); the regression suites listed in task 10 green in isolation;uv run ruff check . && uv run pyrightclean.- One
--no-gpg-signcommit; 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_editedprecedent).documents.created_atNOT NULL, server-defaulted tonow()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
timestamptzat 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
unchangedfor every existing gate (nosources_metabump, no overview/summary regeneration, no staleness) and is counted in a NEW additivedates_updatedcounter (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<document>block (aftertitle); thereadresult's second linedate: YYYY-MM-DD(first line byte-identical — the mock header contract); thelsFILE 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)— defaults0.001/365days, env-tunable (BOR_RECENCY_BOOST,BOR_RECENCY_HALF_LIFE_DAYS),0= byte-identical off, negatives fail startup loudly (the house validator pattern). Applied once inretrieve()afterfuse(). 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:
CreatedbetweenChunksandIndexed. Folders/sources:UpdatedbetweenDocumentsandDescription. Clicked document: theCreatedbadge in the top meta row, beforeIndexed. - D9 — Folder dates are derived. No folder date storage:
updated_at= the subtree's max documentcreated_at, computed in the pure tree builder (one concept end to end — the same recursion that countsdocuments);Nonefor an empty source. - D10 — No clone-strategy change (phase 28 stands).
--depth 1stays. 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
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"