Files
brain-of-reese/AGENTS.md
T
ducoterra a45f744604
Build and Push Containers / build-and-push-app (push) Successful in 2m5s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 194_sync_pending_count
**Phase 194 — final verification pass: all green.**

**Verified (all 5 tasks already complete; nothing re-implemented needed)**
- `app/rag/importer.py`: `_prescan_pending` + `_pending_hash_stmt` (scalar `documents.file_hash`, own session, ONE `asyncio.to_thread` span, REPLACES the stats pre-walk), `on_scan_progress`, `progress` pair = pending position, `0` branch = byte-identical phase-193 walk.
- `app/config.py`/`settings_catalog.py`/`.env.example`: `sync_pending_count=1` + validator; `app/api/sync.py`: `"scan"` phase, `scan_done`/`scan_total` (additive after `files_total`, kept at every terminal), forced/throttled RSS split; `frontend/assets/sources.js`: `fmtSyncPhaseLabel` scan branch first-arm, `fmtSyncLabel` untouched.

**Tests / lint / coverage (exact commands)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4835 passed**, `app/` 99% (7240 stmts / 16 miss), exit 0
- `uv run pytest tests/e2e/test_sync_pending_count.py -v --no-cov` → **8 passed in 150.81s** (phase suite, isolated)
- 14 sync-lineage E2E suites, one pytest process each (`test_sync_remaining_count`, `…_chunk_progress`, `…_progress_box`, `…_peak_memory_visible`, `…_label_mobile`, `…_upload_progress`, `test_archive_upload_sources`, `…_image_progress`, `…_summary_visibility`, `…_status_freshness`, `test_smoke`, `test_fast_path_deferred_content`, `test_sync_session_reset`, `test_sync_git_freeze`) → **all exit 0**
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**

**Completion criteria** — all met: `N remaining` = predicted change set (E2E arms 1/2/5, first import counts the walk, no-change → no count part); `scan` phase visible + flips to `import`, scan pair reset/kept at terminals (`test_sync_scan_phase.py`); `BOR_SYNC_PENDING_COUNT=0` walk semantics ("7 remaining" arm) + **negative refuses startup naming the field (verified live by importing `app.main`)**; pre-scan off-loop + cancel lands `cancelled` (integration pins); CLI/upload byte-identical (unedited pins green); payload additive (`_PRE_187_STATUS_KEYS | _MEMORY_KEYS | _SCAN_KEYS`); suite green, coverage >90%, lint/types clean.

**Documentation** — 3 fixes: README sync "States" bullet had an **unclosed parenthesis** (pre-existing, 193/194) and the scan-label "total is 0" clause was ambiguous about a `=0` run (it still counts the walk once the import starts); **AGENTS.md rule 17 had a stray `)`** (pre-existing from phase 191). Also verified consistency both directions for all 87 settings ↔ README ↔ `.env.example` ↔ settings catalog, the pages table ↔ `app/main.py` routes, no stale `BOR_` names, and README/AGENTS claims through phase 194.

**Notes/deviations** — no live-infra changes (prod sync knob ships default-on, no deployment touched); no git index/history commands run (all changes left in the working tree for the harness); commit criterion left to the harness as instructed.

**Next pending phase:** none — `.agents/phases/todo/` holds only `194_sync_pending_count`, which is now ready to move to `complete/`.
2026-10-20 06:36:21 -04:00

566 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — Operating Rules for All Agents on This Repo
1. **Always read `.agents/PLAN.md` first.** It contains the architecture, the
LOCKED DECISIONS, the UI/UX standards, the data model, and the roadmap.
Do not design anything that contradicts it.
2. **Follow the phased execution protocol in `.agents/phases/`.** Work through
`.agents/phases/todo/` in numeric order. When a phase is complete, move its
directory to `.agents/phases/complete/`.
3. **Strictly adhere to the LOCKED DECISIONS** (PLAN §2). If you believe a
LOCKED decision is wrong, stop and flag it — do not silently deviate.
4. **"One Story, One Phase":** each user story in `.agents/user_stories/`
corresponds to a distinct execution phase, and **every** phase — story-driven
or owner-directed — includes its own dedicated Playwright E2E test suite
(one test file for the phase, run in isolation).
5. **"UI Structure Check":** before finalizing any UI component, verify that
it follows the layout principles in `.agents/PLAN.md` §7 (proper container
usage, the 72rem centered reading column — every page rides the 72rem
container at every viewport; phase 100 supersedes §7.1's 46rem chat
column, full-width tables on the catalogue pages — no skinny
wasted-space lists;
the catalogue tables (RAG, Sources, History) are bounded scroll
panels — `max-height: min(70vh, 64rem)` — with a sticky header row;
a clipped action label shows an ellipsis hint (`.cell-clip`) with
the full text kept reachable via the control's aria-label
(phase 156); an in-flight turn never paints an empty answer
bubble — the box is created empty as the reasoning anchor and
`.msg .bubble:empty { display: none }` hides it until the first
answer token (phase 166); an answer's inline `[text](url)`,
`![alt](src)` and `####`+ heading render through the ONE shared
renderer into an allowlist-gated `a.md-link` (underlined, never a
`javascript:`/`data:` element), a container-capped
`img.md-img` (`max-width: 100%` — no horizontal overflow at 375px)
and an `h5`/`h6` sized into the existing type scale — never printed
reference text (phase 180); an SVG document paints as a
graphic — the viewer's image block adds
`.doc-image-img--vector` (`width: 100%`, `object-fit: contain`)
when `format` is `svg` so a `viewBox`-only drawing spans the
`.doc-image` card instead of the 300×150 default box, while the
chat figure and RAG-table thumbnail keep their fixed caps and the
bytes stay an `<img>` src (never inlined) (phase 181); the
document viewer's page strip is
CLASS-scoped —
the primary class shows the strip in place of the text, the
additive class never hides the text: Text/Pages toggle group
(`aria-pressed`, Text default) with the strip behind it
(phase 163); a lazily-drilled KB-tree level keeps the level the
user is ON rendered while its node fetch runs, and its ONE
in-flight row (`role="status"`) / alert + Retry row
(`role="alert"`, a real `<button>`) sits inside that same bounded
scroll panel — and the stat cards read the server's `totals`,
never a walk of a depth-limited tree (phase 167); an answer's
code block is EITHER verbatim-and-badged OR plainly the model's —
never a silently-substituted hybrid: the ` ```bor ` locator fence
renders through the ONE shared renderer as
`.verbatim[data-bor-locator]` with the model's body DROPPED
(`renderMarkdown(md, { verbatim: true })`, off by default so every
other surface is byte-identical), styled from palette variables only
with a `min(40rem, 70vh)` scroll body, a sticky `source/path`
caption and a `user-select: none` line-number gutter, while the
plain `.code-block` wrapper carries a Copy button and no badge
(phase 182); those placeholders hydrate against the frame's
`answer_text` — or, since phase 192, against the additive
`verbatim_block` SSE frame (the eighth event type: one
`done.answer_text` item plus `type`) that carries a successful
`show_text` block the MOMENT the call succeeds, AFTER that round's
`tool` / `tool_result` frames and BEFORE the next model round, so the
panel lands where the model introduced the bytes instead of popping in
after the answer — through the ONE panel client
(`frontend/assets/verbatim.js` — a classic script loaded AFTER
`markdown.js` on the chat AND shared pages, no second locator grammar,
a plain string compare against the server's canonical `locator`, the
caption's numbers always the SERVER's extent, an unresolvable locator
painting the pinned sentence `I could not verify this against the
knowledge base` with no badge, no body and none of the model's bytes,
no panel left at "Retrieving the exact text…" after a terminal frame
(the `error` frame, the 300 s guard, Stop and the failure funnel all
settle it), a MID-TURN pass (`hydrateVerbatimNow`) that settles ONLY the
pending placeholders its ONE block matches — every other fence STAYS
`.verbatim-pending`, because burning one is how a still-resolvable fence
paints the honest sentence — and ONE canonical locator painting ONE panel
(`dropPlacedTwin`: the fence wins over the mid-turn appended twin, the scan
is PER LOCATOR so two honest ranges of one file stay two panels, it is
bounded by the `.msg` wrap (or the root when there is no wrap — a missing
wrap narrows the scan, it never switches the dedupe off), and a
pending/unresolved node is never a placement), the Copy button's clipboard
content equal to the server string exactly, and the shared page's panel Copy
button + plain `/document.html` link the ONE owner-directed exception to that
page's zero-controls rule) — phase 182 task 07, phase 192 tasks 01–04, the
mid-turn landing (panel on screen BEFORE `done`, the fence winning the
same-locator race, two ranges staying two panels, the `answer_text=False`
wire byte-identical) pinned through the UI by
`tests/e2e/test_verbatim_inline_midturn.py`), and
the sync's peak-memory
clause is TRAILING TEXT on the result line that already exists
(` · peak <N> MB` on the RAG page's `#sync-progress` counts line
or "Sync cancelled", written by the ONE `fmtSyncPeakClause` off
`rss_peak_mb ?? rss_mb` — no new control, no new live region, no new
element, no clause in a running label or in the failed terminal's
empty box, and NOTHING rendered when the pair is null — phase 187
task 03) and meets WCAG 2.1 AA basics (semantic
landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
stream).
6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically
from within the FastAPI application. No `<script src="https://…">` or
`<link href="https://…">` tags in HTML templates unless bundled locally.
An integration test enforces this on every served page except
`/doc-edit.html` (the parametrized no-CDN check in
`tests/integration/test_api.py` — the shell, the viewer, the login and
the shared page).
7. **"Debugpy Check":** any new entrypoint must keep the conditional import —
`debugpy` is imported **only** when `DEBUGPY=1` (see
`app/core/debugging.py`); default off, never imported otherwise.
8. **Git protocol:** Git is mandatory. One **atomic, professional commit per
completed phase** (Conventional Commits, e.g. `feat(rag): stream RAG
answers over SSE with source citations`). Always append `--no-gpg-sign`
(the repo also sets `commit.gpgsign=false`). `.agents/` is **tracked and
committed** with the phase — only runtime artifacts (`.agents/phase-sessions/`,
`.agents/pipeline.log`) are gitignored.
9. **Test gates are non-negotiable:** a phase is complete only when unit +
integration tests pass, `app/` coverage is **>90%**
(`uv run pytest --cov=app --cov-report=term-missing`), and the phase's
Playwright E2E file passes **in isolation**
(`uv run pytest tests/e2e/test_<story>.py -v --no-cov`).
10. **Amply log, visibly feed back:** server-side, log the required
per-turn line (PLAN §9 — phase 182 appends `verbatim_blocks=N` after
`scaffold_stripped=N`: the verbatim display blocks actually SENT on
the `done` frame, `0` on every turn without them, deflected included —
never the phase-192 mid-turn `verbatim_block` frames, which are not
counted anywhere else)
**and** the per-response byte-budget line
(`app/core/response_budget.py` — one
`api method=… path=… status=… bytes=… enc=… duration_ms=…` line per
`/api/*` response, INFO at/over `BOR_LOG_LARGE_RESPONSE_BYTES`, read it
with `grep ' :: api '`, audit it with
`uv run python -m scripts.api_payload_audit`, phase 167) **and** the
sync's memory fields (phase 187 — `app/core/memory.py` reads PROCESS
RSS, never the host's cgroup figure, and never raises: a `None`
renders `?` through the ONE `fmt_mb` helper. `rss_mb=` is appended to
`sync: started …` and to the importer's `import: summary …`, and
`rss_mb=` + `rss_peak_mb=` to all three terminals (`sync: done …`,
`sync: cancelled by user …`, and the sanitized one-line
`sync: failed state=failed … error=…` that sits beside — never in
place of — the `logger.exception` traceback). Append-only: no
pre-existing field renamed, reordered or re-typed, so every older
grep still matches; read them with
`grep ' :: ' server.log | grep ' rss_peak_mb='`. Phase 187 task 03
puts the SAME pair on the status: `SyncStatus.rss_mb` /
`rss_peak_mb`, written by the ONE `_sample_rss` — forced at the run
start, at every `phase` transition and in every terminal (which keep
the FINAL pair beside the final counts, the phase-64 convention, and
log exactly what the payload reports) — and throttled to one read per
`_RSS_SAMPLE_INTERVAL_S` (5 s) from the per-file progress hook, so a
27k-file walk costs ~600 `/proc` reads and not 27k (LOCKED A4).
`GET /api/sync/status` reports both ADDITIVELY as `rss_mb` /
`rss_peak_mb` (`int | null`; `null` is "no reading", never 0) and the
failed funnel samples too, because the run that died is the run whose
memory matters. That ONE sampler also owns the pair's invariant: the
kernel refreshes `ru_maxrss` lazily, so a raw peak can sit BELOW the
raw `VmRSS` (measured 164 vs 162) and `_sample_rss` lifts it to the
current reading — every surface (both lines, the payload, the page's
clause) therefore satisfies `peak >= current`, never a peak below the
run's own footprint, and an unknowable peak is never fabricated from
the current reading); UI-side,
honor the "never stale" feedback contract (PLAN §7.4). Phase 188 adds
the retrieval tier's ONE operator-visible line: when
`BOR_RETRIEVAL_EF_SEARCH` sits below the A7 candidate count
`_vector_candidates` logs `retrieval: ef_search=%d raised to %d (A7
candidate count)` at WARNING and runs the search at the candidate
count anyway — the clamp is never silent, and the healthy
(ef_search >= candidate count) turn logs nothing. The value itself
reaches Postgres as `SET LOCAL hnsw.ef_search` inside the turn's own
transaction (LOCKED A4: no global GUC, nothing survives the request or
a pool recycle). Task 04's live gate (the 1,000-document live-replica KB,
the live embedding endpoint, question vectors PINNED so the access path is the
one variable — `benchmarks/phase188_live_gate/`) found no A8, top-5 or
`top_score` difference at any
depth and shipped `retrieval_ef_search = 400`: at the candidate count the
graph recovered the exact top-100 for 0.9814 of rows on average (0.94
worst), at 400 for all of them, and NO threshold
(`relevance_threshold`, `lexical_support_floor`, `name_hit_bonus`,
`hybrid_top_k`, `hybrid_vector_candidates`) moved to get there (LOCKED A3).
Two things the operator needs from the same run: building the index costs
~161 s per 417k chunks and ~1.6 GB (3.9 KB/row at `m = 16`, 768-dim), and
`maintenance_work_mem = '512MB'` is NOT applicable in this container —
`/dev/shm` is 63 MB, so a raise above that dies with `could not resize
shared memory segment … No space left on device` and leaves an INVALID
index for the migration's self-heal to drop and rebuild (observed live);
the container's 64 MB default builds it fine.
11. **Never block the event loop:** anything blocking (a `subprocess`,
synchronous file/network I/O, a heavy walk) reached from an `async`
handler or a spawned background coroutine goes through
`asyncio.to_thread` (or `create_subprocess_exec`). The sync's git
calls do (phase 179: a long `git clone` used to park the single
uvicorn loop and freeze the whole HTTP surface, the 2 s
`GET /api/sync/status` poll included — pinned by
`tests/integration/test_sync_git_freeze.py` with a 3 s stub and by
`tests/e2e/test_sync_git_freeze.py` with a real ~9 s `file://`
clone). A cooperative cancel unblocks the *run* at the `to_thread`
await; the worker thread itself runs to completion (threads are not
interruptible) while the loop keeps serving — pinned by the same E2E
module's cancel-during-clone test. Since phase 186
`scripts/git_sync.py` has **two** git spawn sites — the buffered
`run_git` and the streaming `stream_git_lines` (the commit-date walk
yields `git log` line by line instead of holding the whole history in
RAM) — and **both** are off-loop via `asyncio.to_thread`; the
`subprocess.` single-site intent survives as "one per shape, both
owned there", enforced by
`tests/unit/test_git_sync.py::test_only_two_git_spawn_sites`, and
the owner-visible date contract through the stream (last-commit
dates, the mtime fallback, the D1 manual lock) by
`tests/e2e/test_git_log_stream_dates.py`.
12. **Never hydrate what you delete:** `Document.chunks` is
`passive_deletes=True` (phase 183), so deleting a document removes its
chunk rows **in the database** (`chunks_document_id_fkey` is
`ON DELETE CASCADE`, alembic 0001) and a mass prune costs
O(documents). The importer's prune and the source-removal endpoint both
rely on it. Do not remove the keyword, and do not add a delete path
that SELECTs `chunks` (or a `Chunk.embedding`) on its way to deleting
them — one hydrated 768-dim embedding is ~24.8 KiB of Python, which is
how the 2026-10-15 sync's 3,008-document prune reached 4 GB and locked
up the VM. Pinned by
`tests/integration/test_prune_cascade_statements.py` (statement count,
zero orphan chunk rows, and a live-FK drift guard that fails the suite
if the cascade clause ever disappears — because then
`passive_deletes=True` would silently orphan rows). The
`doc.chunks = [...]` **re-embed** path is the deliberate, documented
exception (collection replacement legitimately needs the loaded
collection; changed files only).
13. **Release the sync session at the proven-safe points only:** an import
run holds one session and `expire_on_commit=False` (`app/db.py`) means a
commit releases nothing, so `_unchanged_doc`'s `file_hash` stamp keeps
every walked `Document` — content included — resident in the session's
own strong `dirty` set (not collectable): memory grew with the KB. The
cadence is therefore `session.expunge_all()` every
`BOR_SYNC_SESSION_RESET_FILES` files at the TOP of the per-file loop and
once per source before the two post-passes (phase 184; `0` = never, the
pre-phase rollback). `expunge_all` DISCARDS unflushed changes, so never
move it into `_index_file`, `_unchanged_doc`, `_store_summary` or a
post-pass — their locals are live across statements — and never add a
reset before a file's per-file commit. Pinned by
`tests/integration/test_import_session_retention.py` (bounded with the
cadence on, unbounded with it `0`, byte-identical summaries and DB) and
through the UI by `tests/e2e/test_sync_session_reset.py` (two module apps,
one per cadence: every document exactly once, the all-`unchanged` re-sync,
and the `0`-cadence run byte-identical to the `3`-cadence one).
14. **The prune reads six columns and commits in batches:** `_prune`
selects its candidates through
`load_only(id, source, path, is_image, image_path, page_count)`
`order_by(Document.id)` — the exact set the phase-122 images-off guard,
the phase-126 parent-PDF rule and the image/page copy cleanup read, and
NOT `content` (252 MB of markdown per full KB walk) — and commits every
`BOR_SYNC_PRUNE_BATCH` deletes (`0` = the pre-phase single commit;
phase 184). Never read another `Document` attribute inside that loop (a
deferred column costs one lazy SELECT per document and unbounds the
walk — no `raiseload` paper-over, add the column instead), and never
swap the per-document `session.delete(doc)` for a bulk `delete()` (the
guards, `changed_paths` and the log line depend on the per-document
shape). A non-zero batch deliberately trades the all-or-nothing prune
for a prefix (LOCKED A2): `seen` is recomputed from disk, so the next
sync converges. Pinned by
`tests/integration/test_import_session_retention.py` (the captured
`SELECT`'s column list, exactly one document-wide `SELECT` per prune
(a second one is a per-row lazy load), the commit counts at `10` and
`0`, both guards on light rows) and
`tests/e2e/test_sync_session_reset.py` (the five-document prune at batch
`2` through the RAG page (`/sources.html`): both folders cleared, the
survivors' chunks intact, zero orphan chunk rows).
15. **The unchanged fast path reads metadata, never markdown:** the four
row lookups that share it (`_index_file`, `_index_pdf_file`,
`_index_one_embedded_image`, `_index_image_file`) all go through the ONE
statement `_doc_row_stmt` (`app/rag/importer.py`), which defers
`Document.content` and NOTHING ELSE (phase 185, LOCKED A1) — the row
load is the only place the fast path gets its row, and hydrating
`content` for every inspected file is what put ~252.6 MB of markdown in
front of a run that re-indexed 217 of ~27k files. Do not add a fifth
whole-entity `select(Document).where(Document.source == …, Document.path
== …)`, do not swap the `defer` for a `load_only` allowlist (a future
column would silently become lazy and resurface as an N+1), and do not
add `raiseload`/`noload` (LOCKED A2): `_unchanged_doc`'s phase-118
summary-NULL backfill genuinely reads `doc.content` and must pay one
per-row, one-time SELECT instead of raising. Pinned by
`tests/integration/test_import_fast_path_columns.py` (the captured
fast-path `SELECT` names no `documents.content`, exactly one `SELECT`
per unchanged file with a summary, exactly one PK follow-up for the
backfill), `tests/unit/test_importer_defer_content.py` (`content` is
the only deferred column, the four loads share the helper, no
`raiseload`/`noload` in the importer) and
`tests/e2e/test_fast_path_deferred_content.py` (the owner-visible
parity: the summary-NULL backfill survives the deferral with exactly
one `is_summary` chunk, an unchanged doc's viewer shows the full
stored text, a changed file re-indexes and the fast path still fires
after).
16. **The cosine tier is indexed — keep it indexed, and keep `0029` the ONE
definition of that index:** `ix_chunks_embedding_hnsw` (HNSW over
`chunks.embedding`, `vector_cosine_ops`, `m = 16`,
`ef_construction = 64`) exists because every turn's
`ORDER BY embedding <=> :vec LIMIT 100` full-scanned ~3 GB on the
interactive path (phase 188). Build and drop it ONLY through migration
`0029` (`CREATE/DROP INDEX CONCURRENTLY` inside
`op.get_context().autocommit_block()`, which pre-drops an INVALID remnant
and raises if the build does not validate) — never an inline
`CREATE INDEX` in app code, a test, or a runbook step; never a second
vector index, a `halfvec` cast, or a rewritten `ORDER BY` (the `<=>`
operator is what the graph serves, LOCKED A3/A6). A test that needs the
index OFF moves the SCHEMA (`command.downgrade(cfg, "0028")` /
`command.upgrade(cfg, "0029")`) after rolling back its own session — an
open snapshot parks a `CONCURRENTLY` build indefinitely — and asserts the
catalog state it claims (`pg_index.indisvalid`, `pg_get_indexdef`).
`hnsw.ef_search` is set with `SET LOCAL` inside the turn's own transaction
and the `int()`-coerced setting is the only source (no global `SET`, no
pool-wide state, nothing survives a recycle); the shipped default is
**400** because that is the depth the live gate measured exact-top-100
parity at, so "tuning" it back to the candidate count is a recall change,
and closing a recall gap by moving an honesty threshold is forbidden
(LOCKED A3). The `ef_search` a turn gets is
`max(retrieval_ef_search, hybrid_vector_candidates)` with a WARNING when
clamped. Pinned by `tests/integration/test_chunks_hnsw_index.py` (DDL
round trip + INVALID self-heal + drift guard),
`tests/integration/test_chunks_hnsw_recall.py` (the tier's captured
statement names the index, `SET LOCAL` reaches the connection, recall at
the shipped depth with the index paths forced — `enable_seqscan`,
`enable_indexonlyscan` AND `enable_bitmapscan` off, because a bitmap scan
through `ix_chunks_document_id` otherwise disguises a full read and flips
the plan on a shared dev DB — and the unforced plan asserted only when the
relation holds nothing but the module's own corpus), and
`tests/e2e/test_vector_index_recall.py` (the owner-visible arm: the SAME
cited document with the index dropped and restored, the index VALID after a
real importer run, the near-miss still deflecting in both arms, and the
clamp read off the app's own captured stdout at `=150` / `=50`). That
suite's sentinels are calibrated against
`tests/fixtures/hnswkb/` — an 11-note tracked KB whose BYTES own the
quoted cosine/lexical margins, so editing a note there moves the numbers in
the module docstring and the pins together.
17. **A source label is an alias, never an identity (phase 191):** the
`source_labels` row (alembic `0030`, PK = the DERIVED source name
exactly as `Document.source` carries it) holds the owner's
`display_name` / `label` / `tags`, and only
`app/api/source_labels.py` writes it — `GET /api/source-labels` (the
catalogue: the `effective_sources` rows of BOTH kinds — or the
`BOR_GIT_SOURCES` names while the table is empty — plus every indexed
document source no row derives, de-duplicated and sorted, ONE
`all_labels` read for the whole list), `PUT` (partial update: omitted
keeps, explicit `null` clears, `tags: []` clears) and `DELETE` (the
reset); `require_admin` router-wide, and every name resolution goes
through `app.rag.source_removal.resolve_source_name` — never
re-derive the basename here or the row keys nothing. A `PUT` for a
name that is neither a registry row's derived name nor an indexed
document source is 404 (no orphan label rows) and a validation
failure writes NOTHING. NEVER rename, resolve or join on the display
name: `Document.source`, the `source/path` read/grep/`show_text`
locators, the citations, the KB tree and the verbatim caption stay on
the derived name, so a rename cannot invalidate a saved chat, a
shared page or a locator — and the name reaches the model only
through the prompt/retrieval layers (tasks 04/05), never by replacing
the identity in a chip, a table or a caption. Pinned by
`tests/integration/test_source_labels_api.py` (the 403 gate for
anonymous AND token users, the git+local+upload union sorted and
de-duplicated at three SELECTs with no `documents.content` read, the
partial-update shape, every cap as the framework's 422 with the row
byte-identical, both 404 arms, the `ValueError` → 422 funnel, and the
"a rename writes nothing but the label row" arm).
The Sources-page editor (task 03) is the owner-visible half of the SAME
contract and must stay that way: its row list comes from
`GET /api/source-labels` (the catalogue union — never a re-derived
basename, never a join on the display name), the row's identity cell is a
read-only `<code>` of the DERIVED name (an input there WOULD be the
alias-contract bug), Save/Reset `PUT`/`DELETE` the per-source path and
re-read the ROW from that response (no second GET), the save state is
scoped PER ROW (`saveInFlight` — one request per row, the `finally` always
settles it, the 404 note names the arm, a collapse discards the draft) —
and a FAILED save's note SURVIVES that settle: `updateLabelRowUI` returns
before the dirty hint whenever the line carries `.is-error`, so
"That source no longer exists — reload the page." cannot be painted over
by "unsaved changes" in the tick that wrote it (the defect the task-06
story suite walked into); only the next edit (`clearLabelError`) or a
collapse clears a note, never the settle path,
and the Tags token input's chips reuse the phase-89 tag rule
(`.source-label-tag` sits in the `.git-source-hidden-count` rule group — no
bespoke tag-input CSS). Pinned by
`tests/unit/test_source_labels_editor_ui.py` and, through the real UI over
one git + one local + one uploaded source, by
`tests/e2e/test_source_name_labels.py` (task 06: the editor saves and
re-populates all three kinds, a question that USES the name moves that
source's document into the seeded `<documents>` tier with
`name`/`label` on the wire while the same row with a name-free question
moves nothing, the vanished-source 404 note is visible, Reset reverts the
tier, and the alias never appears in a locator, a nearby-doc link, the KB
tree or the viewer); the chat source chips, the RAG
source tree and the verbatim caption keep the derived name untouched.
The selection boost (task 04) is the retrieval half of the SAME
contract: ONE `all_labels` read per turn at the
retrieval boundary (`app/api/chat.py`, after `retrieve`, before the
first selection call) flags `RetrievedChunk.source_name_hit` on the
chunks whose `document.source` is a D2 hit (the pure
`question_name_hits` rule applied per view — a loop over the few
dozen chunks, never a per-chunk query; that ONE result is also what
task 05's prompt surfaces consume, so a turn issues one label query
and the standalone `question_name_hit_sources` wrapper never runs in
one), and `_selection_order` adds
`source_name_hit_bonus` (default 0.005; `0` = the byte-identical
kill switch; a negative value refuses startup) as a SECOND,
independent bonus — once per DOCUMENT, at most two bonuses stacked
with the phase-119 `name_hit_bonus` (bounded 0.010 on the fused
scale), SELECTION layer only (`score`/`cosine`/`fts_hit`/
`query_log.top_score` and the per-turn log line untouched). Pinned
by `tests/unit/test_retriever.py` (the kill-switch byte-identity,
the bounded two-bonus application, the `fuse` OR-merge, the three
selection helpers pulling the knob from settings).
The prompt surfacing (task 05) is the model-facing half of the SAME
read: `build_high_prompt(names=…)` gives the suggested `<document>`
block the ADDITIVE ` name="…"` (immediately after `source="…"`) and
` label="…"` attributes — the order is locked
(`source` → `name?` → `label?` → `path` → `title` → `date`), the two
values escape through the ONE `_attr` helper, and the identity
attributes keep their pre-phase unescaped bytes; `regenerate_overview`
derives the KB-overview INPUT lines' name map from its own ONE
`all_labels` read (`build_overview_prompt(names=…)` swaps the raw
source name for the display name in the lite model's input ONLY — the
stored outline and its deterministic `Sources: …` tail stay derived
name). An unset field adds NOTHING (never `name=""`), tags never
render, the LOW prompt never carries a name, and no map / no entry is
byte-identical to pre-phase on BOTH builders (the kill pins in
`tests/unit/test_prompts.py` and `tests/unit/test_overview.py`, which
also marker-pin the one-read-per-surface contract, as
`tests/unit/test_chat_gate.py` does on the chat call site).
18. **The sync's running count is the WORK, and the pre-scan that predicts
it is a scalar read off the loop (phase 194):** `import_sources` predicts
the run's change set BEFORE the walk (`_prescan_pending`) so the
`progress` hook's pair is the position in the PENDING set (`total` = its
size, `done` = the 1-based position in it, held — not advanced — by a file
the scan did not predict), which is what makes the Sources page's
`N remaining` mean "files left to re-index" instead of "files left to
walk" (the owner's "1216 remaining" on a ~10,000-file KB with ~10 changes,
2026-10-19). The prediction uses the fast path's OWN predicate —
`_file_hash` (the streamed 1 MiB-chunk sha256) against
`_pending_hash_stmt`, a SCALAR `SELECT documents.file_hash` — so a stored
NULL (no row, or a pre-phase-147 row) is predicted changed AND really
re-indexed and stamped by the loop: the prediction never lies about the
stored-hash contract. Keep it that way: **never** turn that read into a
`select(Document)` / `defer` / `load_only` row (phase 184/185 — a 10,000-file
scan would retain 10,000 rows and re-create the memory incident; the scan
therefore also reads through a session of its OWN, since a `Session` is not
thread-safe and the span's worker runs to completion after a cancel has
moved the run on), never add a THIRD count-only walk (the pre-scan REPLACES
the phase-64 stats pre-walk, which survives as the `0` branch), never move
the scan onto the event loop (phase 179: ONE `asyncio.to_thread` span — a
10,000-file hash+SELECT pass inline parks the single uvicorn loop and
starves the 2 s `GET /api/sync/status` poll), and never drop the loop's own
fresh `_file_hash` (the scan's digest is the prediction, the loop's is the
truth). The scan runs ONLY for a caller that passes `progress` AND while
`llm.settings.sync_pending_count` is truthy — read ONCE before the source
loop (phase 184's read-once discipline) — so the CLI
(`scripts/import_docs.py`) and the upload job stay byte-identical and pay
nothing; `BOR_SYNC_PENDING_COUNT=0` is the exact phase-193 walk-position
rollback and a negative value refuses startup naming the field (the
`agent_max_rounds` pattern). `summary.files` (the `import: summary … files=`
log count), `seen`, the prune, the date refresh, the terminal `counts_line`
and every pre-existing log line are UNCHANGED — the scan builds `pending`,
never `seen` — and an empty KB keeps the phase-64 shape (scan total 0, no
callback, `total` 0 → no count part). Pinned by
`tests/unit/test_sync_pending_count.py` (the gate both ways, the read-once +
`to_thread` markers, the exactly-`documents.file_hash` projection, the four
predicate states + the ignore-rule parity, the unreadable-file arm, the
`done`-as-pending-position, the switch values) and
`tests/integration/test_sync_pending_count.py` (the parity across the fresh
/ unchanged / incremental arms, `len(session.identity_map) == 0` at the
first `progress` call, the heartbeat + thread-identity off-loop pin, and the
cancel-at-the-await arm), and through the real Sources page by
`tests/e2e/test_sync_pending_count.py` — TWO live module apps over one
delay-injecting mock model (the phase-193/107 fixture shape): the first
sync (pending == walk, so the phase-193 frames stand), the K-of-N owner
case with the count HOLDING at `K remaining` while `current_file` names the
UNCHANGED files (the unchanged files in that run cost the phase-118
summary backfill — a real, model-bound, loop-free span — which is what
makes the hold observable in a browser at all), a SECOND app booted with
`BOR_SYNC_PENDING_COUNT=0` rendering the walk numbers for the same fixture
(`7 remaining`, not `1`), the no-change run answering `files_total == 0` on
every tick, the empty source never leaving `"scan"`, and the 1,000-file
pre-scan whose position is caught climbing THROUGH the running server (the
phase-179 contract) before the import answers the predicted `2`.
The STATUS half of the same design (phase 194, task 02) is the machine
the page reads: `SyncStatus.phase` is `null` (the model-check +
clone/pull prelude) → **`"scan"`** (set immediately before
`import_sources`, REPLACING the pre-phase `= "import"` — what runs first
there is the pre-scan) → `"import"` (flipped by the FIRST `progress` call
INSIDE the existing `_hook` closure — the importer gains no extra
callback, so do not add one) → `"overview"` → `"summaries"`, every
terminal clearing it. `SyncStatus.scan_done` / `scan_total` (landed by ONE
`_scan_hook` passed as `on_scan_progress`) are the scan's own position —
reset with the run, `0/0` on the `0` rollback and for a scan that predicted
nothing, and KEPT at every terminal beside the final file counts (the
phase-64 keep-final-values convention); `current_file` stays `null` through
the phase (the scan names no file). The `GET /api/sync/status` payload gains
`scan_done` / `scan_total` ADDITIVELY after `files_total` — every
pre-existing key keeps its name, type and position, and the additive chain
(`_PRE_187_STATUS_KEYS | _MEMORY_KEYS | _SCAN_KEYS`) is the pin that keeps it
that way. Sampling follows phase 187 exactly: the `"scan"` set and the
`scan`→`import` flip are TRANSITIONS and `_sample_rss(force=True)`, while
`_scan_hook` samples UNFORCED, so a 10,000-file scan costs one `/proc` read
per `_RSS_SAMPLE_INTERVAL_S` and not one per file (LOCKED A4). The degenerate
empty KB stays `"scan"` (no `progress` call exists to flip it) and the
terminal clears it. Pinned by `tests/integration/test_sync_scan_phase.py`
(the real-run scan frames + both pairs kept at the terminal — divergent on
the incremental arm, 3 walked vs 1 predicted; the choreographed flip frame
carrying the predicted denominator; the additive key set idle → terminal;
the forced/throttled sample sequence `null → scan → import → overview →
summaries → null` with EXACTLY one read inside the scan phase; the empty-KB
arm on both sides; the cancel-during-scan terminal with the scan counts kept
and nothing indexed; the `0` switch reporting `0/0` scan and the WALK file
count) plus the retargeted additive pins in `tests/unit/test_sync_button.py`
and `tests/integration/test_sync_api.py` (a canned import that fires no
per-file hook stays in `"scan"` — that is why the 409-concurrency pin waits
on `"scan"` now).
The UI half of the same design (phase 194, task 03) is ONE branch:
`fmtSyncPhaseLabel` renders `phase === "scan"` as main `Scanning…` (NEVER a
file — `current_file` is null in the phase and the branch must not read it)
plus the count part `(scan_done/scan_total)` in the SAME count slot the import
phase's `N remaining` uses, so `setSyncLabel`'s hidden-toggle and the A4
untruncated placement (button title + `#sync-progress`, the sole live region)
carry `Scanning… (n/total)` with no new element, no new live region and no
new writer (the rendered frames — the scan's climb and the import's held
`K remaining` — are pinned on the exact payloads those guards read, served
to the real `sources.js` by `tests/e2e/test_sync_pending_count.py`, because
the 2 s poll cannot be aimed at a frame; the phase-187 peak clause stays on
the TERMINAL line of that same scan-labelled run and appears on NO running
label). Keep it that way: the branch is the FIRST arm (the
`null`/`import` fall-through therefore covers ONLY those two), it carries no
chunk/image part (the scan reads no file), and `scan_total === 0` renders the
BARE label (the phase-64 no-count-part contract — the empty KB and the
`BOR_SYNC_PENDING_COUNT=0` rollback). The scan's walk position surviving as an
`(n/total)` is honest ONLY because the word in front of it is "Scanning": the
owner's objection (TODO L6) was the SYNC count wearing the total's shape, so
never move a `(n/m)` position onto the import label and never rename this
copy to "Syncing". The pair reaches the wrapper through the SAME
whole-status pass-through as the summaries / chunk / image pairs at the two
sync call sites (the poll tick + the load-time re-attach) — do not re-list
the fields there — and `fmtSyncLabel`'s `N remaining` arithmetic, the terminal
`counts_line` and the phase-187 `fmtSyncPeakClause` stay byte-identical (the
clause stays TERMINAL-only — it rides the settle's counts line exactly as
before, never the running scan label, and it still needs no new element).
Pinned by `tests/unit/test_sync_scan_label_ui.py` (the branch order, the exact
`(n/total)` shape + its `total === 0` guard, the whole-status pass-through, the
untouched builder set, and a node run of the REAL label path: `Scanning…
(3/9)` identical on main / count / title / box / aria, the bare `Scanning…`
at `0/0`, every other phase's label byte-identical).
## Quick reference
```bash
podman compose up -d db # start Postgres 17 + pgvector
cp .env.example .env # once; then edit
uv run alembic upgrade head # apply migrations
uv run uvicorn app.main:app --reload # dev server (add DEBUGPY=1 to debug)
uv run pytest # unit + integration
uv run pytest --cov=app --cov-report=term-missing
uv run playwright install chromium # once
uv run pytest tests/e2e/test_smoke.py -v --no-cov
uv run ruff check . && uv run pyright # lint + types
uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt # retrieval regression battery (phases 119/162)
uv run python -m scripts.api_payload_audit # endpoint byte audit vs the 256 KB budget (phase 167; app must be running)
```