**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/`.
38 KiB
AGENTS.md — Operating Rules for All Agents on This Repo
-
Always read
.agents/PLAN.mdfirst. It contains the architecture, the LOCKED DECISIONS, the UI/UX standards, the data model, and the roadmap. Do not design anything that contradicts it. -
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/. -
Strictly adhere to the LOCKED DECISIONS (PLAN §2). If you believe a LOCKED decision is wrong, stop and flag it — do not silently deviate.
-
"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). -
"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),and####+ heading render through the ONE shared renderer into an allowlist-gateda.md-link(underlined, never ajavascript:/data:element), a container-cappedimg.md-img(max-width: 100%— no horizontal overflow at 375px) and anh5/h6sized 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) whenformatissvgso aviewBox-only drawing spans the.doc-imagecard 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'stotals, 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```borlocator 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 amin(40rem, 70vh)scroll body, a stickysource/pathcaption and auser-select: noneline-number gutter, while the plain.code-blockwrapper carries a Copy button and no badge (phase 182); those placeholders hydrate against the frame'sanswer_text— or, since phase 192, against the additiveverbatim_blockSSE frame (the eighth event type: onedone.answer_textitem plustype) that carries a successfulshow_textblock the MOMENT the call succeeds, AFTER that round'stool/tool_resultframes 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 AFTERmarkdown.json the chat AND shared pages, no second locator grammar, a plain string compare against the server's canonicallocator, the caption's numbers always the SERVER's extent, an unresolvable locator painting the pinned sentenceI could not verify this against the knowledge basewith no badge, no body and none of the model's bytes, no panel left at "Retrieving the exact text…" after a terminal frame (theerrorframe, 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.msgwrap (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.htmllink 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 BEFOREdone, the fence winning the same-locator race, two ranges staying two panels, theanswer_text=Falsewire byte-identical) pinned through the UI bytests/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> MBon the RAG page's#sync-progresscounts line or "Sync cancelled", written by the ONEfmtSyncPeakClauseoffrss_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). -
"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 intests/integration/test_api.py— the shell, the viewer, the login and the shared page). -
"Debugpy Check": any new entrypoint must keep the conditional import —
debugpyis imported only whenDEBUGPY=1(seeapp/core/debugging.py); default off, never imported otherwise. -
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 setscommit.gpgsign=false)..agents/is tracked and committed with the phase — only runtime artifacts (.agents/phase-sessions/,.agents/pipeline.log) are gitignored. -
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). -
Amply log, visibly feed back: server-side, log the required per-turn line (PLAN §9 — phase 182 appends
verbatim_blocks=Nafterscaffold_stripped=N: the verbatim display blocks actually SENT on thedoneframe,0on every turn without them, deflected included — never the phase-192 mid-turnverbatim_blockframes, which are not counted anywhere else) and the per-response byte-budget line (app/core/response_budget.py— oneapi method=… path=… status=… bytes=… enc=… duration_ms=…line per/api/*response, INFO at/overBOR_LOG_LARGE_RESPONSE_BYTES, read it withgrep ' :: api ', audit it withuv run python -m scripts.api_payload_audit, phase 167) and the sync's memory fields (phase 187 —app/core/memory.pyreads PROCESS RSS, never the host's cgroup figure, and never raises: aNonerenders?through the ONEfmt_mbhelper.rss_mb=is appended tosync: started …and to the importer'simport: summary …, andrss_mb=+rss_peak_mb=to all three terminals (sync: done …,sync: cancelled by user …, and the sanitized one-linesync: failed state=failed … error=…that sits beside — never in place of — thelogger.exceptiontraceback). Append-only: no pre-existing field renamed, reordered or re-typed, so every older grep still matches; read them withgrep ' :: ' 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 everyphasetransition 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/procreads and not 27k (LOCKED A4).GET /api/sync/statusreports both ADDITIVELY asrss_mb/rss_peak_mb(int | null;nullis "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 refreshesru_maxrsslazily, so a raw peak can sit BELOW the rawVmRSS(measured 164 vs 162) and_sample_rsslifts it to the current reading — every surface (both lines, the payload, the page's clause) therefore satisfiespeak >= 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: whenBOR_RETRIEVAL_EF_SEARCHsits below the A7 candidate count_vector_candidateslogsretrieval: 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 asSET LOCAL hnsw.ef_searchinside 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 ortop_scoredifference at any depth and shippedretrieval_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 atm = 16, 768-dim), andmaintenance_work_mem = '512MB'is NOT applicable in this container —/dev/shmis 63 MB, so a raise above that dies withcould not resize shared memory segment … No space left on deviceand 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. -
Never block the event loop: anything blocking (a
subprocess, synchronous file/network I/O, a heavy walk) reached from anasynchandler or a spawned background coroutine goes throughasyncio.to_thread(orcreate_subprocess_exec). The sync's git calls do (phase 179: a longgit cloneused to park the single uvicorn loop and freeze the whole HTTP surface, the 2 sGET /api/sync/statuspoll included — pinned bytests/integration/test_sync_git_freeze.pywith a 3 s stub and bytests/e2e/test_sync_git_freeze.pywith a real ~9 sfile://clone). A cooperative cancel unblocks the run at theto_threadawait; 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 186scripts/git_sync.pyhas two git spawn sites — the bufferedrun_gitand the streamingstream_git_lines(the commit-date walk yieldsgit logline by line instead of holding the whole history in RAM) — and both are off-loop viaasyncio.to_thread; thesubprocess.single-site intent survives as "one per shape, both owned there", enforced bytests/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) bytests/e2e/test_git_log_stream_dates.py. -
Never hydrate what you delete:
Document.chunksispassive_deletes=True(phase 183), so deleting a document removes its chunk rows in the database (chunks_document_id_fkeyisON 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 SELECTschunks(or aChunk.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 bytests/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 thenpassive_deletes=Truewould silently orphan rows). Thedoc.chunks = [...]re-embed path is the deliberate, documented exception (collection replacement legitimately needs the loaded collection; changed files only). -
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'sfile_hashstamp keeps every walkedDocument— content included — resident in the session's own strongdirtyset (not collectable): memory grew with the KB. The cadence is thereforesession.expunge_all()everyBOR_SYNC_SESSION_RESET_FILESfiles 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_allDISCARDS unflushed changes, so never move it into_index_file,_unchanged_doc,_store_summaryor a post-pass — their locals are live across statements — and never add a reset before a file's per-file commit. Pinned bytests/integration/test_import_session_retention.py(bounded with the cadence on, unbounded with it0, byte-identical summaries and DB) and through the UI bytests/e2e/test_sync_session_reset.py(two module apps, one per cadence: every document exactly once, the all-unchangedre-sync, and the0-cadence run byte-identical to the3-cadence one). -
The prune reads six columns and commits in batches:
_pruneselects its candidates throughload_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 NOTcontent(252 MB of markdown per full KB walk) — and commits everyBOR_SYNC_PRUNE_BATCHdeletes (0= the pre-phase single commit; phase 184). Never read anotherDocumentattribute inside that loop (a deferred column costs one lazy SELECT per document and unbounds the walk — noraiseloadpaper-over, add the column instead), and never swap the per-documentsession.delete(doc)for a bulkdelete()(the guards,changed_pathsand 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):seenis recomputed from disk, so the next sync converges. Pinned bytests/integration/test_import_session_retention.py(the capturedSELECT's column list, exactly one document-wideSELECTper prune (a second one is a per-row lazy load), the commit counts at10and0, both guards on light rows) andtests/e2e/test_sync_session_reset.py(the five-document prune at batch2through the RAG page (/sources.html): both folders cleared, the survivors' chunks intact, zero orphan chunk rows). -
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 defersDocument.contentand NOTHING ELSE (phase 185, LOCKED A1) — the row load is the only place the fast path gets its row, and hydratingcontentfor 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-entityselect(Document).where(Document.source == …, Document.path == …), do not swap thedeferfor aload_onlyallowlist (a future column would silently become lazy and resurface as an N+1), and do not addraiseload/noload(LOCKED A2):_unchanged_doc's phase-118 summary-NULL backfill genuinely readsdoc.contentand must pay one per-row, one-time SELECT instead of raising. Pinned bytests/integration/test_import_fast_path_columns.py(the captured fast-pathSELECTnames nodocuments.content, exactly oneSELECTper unchanged file with a summary, exactly one PK follow-up for the backfill),tests/unit/test_importer_defer_content.py(contentis the only deferred column, the four loads share the helper, noraiseload/noloadin the importer) andtests/e2e/test_fast_path_deferred_content.py(the owner-visible parity: the summary-NULL backfill survives the deferral with exactly oneis_summarychunk, an unchanged doc's viewer shows the full stored text, a changed file re-indexes and the fast path still fires after). -
The cosine tier is indexed — keep it indexed, and keep
0029the ONE definition of that index:ix_chunks_embedding_hnsw(HNSW overchunks.embedding,vector_cosine_ops,m = 16,ef_construction = 64) exists because every turn'sORDER BY embedding <=> :vec LIMIT 100full-scanned ~3 GB on the interactive path (phase 188). Build and drop it ONLY through migration0029(CREATE/DROP INDEX CONCURRENTLYinsideop.get_context().autocommit_block(), which pre-drops an INVALID remnant and raises if the build does not validate) — never an inlineCREATE INDEXin app code, a test, or a runbook step; never a second vector index, ahalfveccast, or a rewrittenORDER 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 aCONCURRENTLYbuild indefinitely — and asserts the catalog state it claims (pg_index.indisvalid,pg_get_indexdef).hnsw.ef_searchis set withSET LOCALinside the turn's own transaction and theint()-coerced setting is the only source (no globalSET, 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). Theef_searcha turn gets ismax(retrieval_ef_search, hybrid_vector_candidates)with a WARNING when clamped. Pinned bytests/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 LOCALreaches the connection, recall at the shipped depth with the index paths forced —enable_seqscan,enable_indexonlyscanANDenable_bitmapscanoff, because a bitmap scan throughix_chunks_document_idotherwise 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), andtests/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 againsttests/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. -
A source label is an alias, never an identity (phase 191): the
source_labelsrow (alembic0030, PK = the DERIVED source name exactly asDocument.sourcecarries it) holds the owner'sdisplay_name/label/tags, and onlyapp/api/source_labels.pywrites it —GET /api/source-labels(the catalogue: theeffective_sourcesrows of BOTH kinds — or theBOR_GIT_SOURCESnames while the table is empty — plus every indexed document source no row derives, de-duplicated and sorted, ONEall_labelsread for the whole list),PUT(partial update: omitted keeps, explicitnullclears,tags: []clears) andDELETE(the reset);require_adminrouter-wide, and every name resolution goes throughapp.rag.source_removal.resolve_source_name— never re-derive the basename here or the row keys nothing. APUTfor 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, thesource/pathread/grep/show_textlocators, 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 bytests/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 nodocuments.contentread, the partial-update shape, every cap as the framework's 422 with the row byte-identical, both 404 arms, theValueError→ 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 fromGET /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/ResetPUT/DELETEthe 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, thefinallyalways settles it, the 404 note names the arm, a collapse discards the draft) — and a FAILED save's note SURVIVES that settle:updateLabelRowUIreturns 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-tagsits in the.git-source-hidden-countrule group — no bespoke tag-input CSS). Pinned bytests/unit/test_source_labels_editor_ui.pyand, through the real UI over one git + one local + one uploaded source, bytests/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 withname/labelon 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: ONEall_labelsread per turn at the retrieval boundary (app/api/chat.py, afterretrieve, before the first selection call) flagsRetrievedChunk.source_name_hiton the chunks whosedocument.sourceis a D2 hit (the purequestion_name_hitsrule 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 standalonequestion_name_hit_sourceswrapper never runs in one), and_selection_orderaddssource_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-119name_hit_bonus(bounded 0.010 on the fused scale), SELECTION layer only (score/cosine/fts_hit/query_log.top_scoreand the per-turn log line untouched). Pinned bytests/unit/test_retriever.py(the kill-switch byte-identity, the bounded two-bonus application, thefuseOR-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 ADDITIVEname="…"(immediately aftersource="…") andlabel="…"attributes — the order is locked (source→name?→label?→path→title→date), the two values escape through the ONE_attrhelper, and the identity attributes keep their pre-phase unescaped bytes;regenerate_overviewderives the KB-overview INPUT lines' name map from its own ONEall_labelsread (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 deterministicSources: …tail stay derived name). An unset field adds NOTHING (nevername=""), 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 intests/unit/test_prompts.pyandtests/unit/test_overview.py, which also marker-pin the one-read-per-surface contract, astests/unit/test_chat_gate.pydoes on the chat call site). -
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_sourcespredicts the run's change set BEFORE the walk (_prescan_pending) so theprogresshook'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'sN remainingmean "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 SCALARSELECT 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 aselect(Document)/defer/load_onlyrow (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 aSessionis 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 the0branch), never move the scan onto the event loop (phase 179: ONEasyncio.to_threadspan — a 10,000-file hash+SELECT pass inline parks the single uvicorn loop and starves the 2 sGET /api/sync/statuspoll), 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 passesprogressAND whilellm.settings.sync_pending_countis 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=0is the exact phase-193 walk-position rollback and a negative value refuses startup naming the field (theagent_max_roundspattern).summary.files(theimport: summary … files=log count),seen, the prune, the date refresh, the terminalcounts_lineand every pre-existing log line are UNCHANGED — the scan buildspending, neverseen— and an empty KB keeps the phase-64 shape (scan total 0, no callback,total0 → no count part). Pinned bytests/unit/test_sync_pending_count.py(the gate both ways, the read-once +to_threadmarkers, the exactly-documents.file_hashprojection, the four predicate states + the ignore-rule parity, the unreadable-file arm, thedone-as-pending-position, the switch values) andtests/integration/test_sync_pending_count.py(the parity across the fresh / unchanged / incremental arms,len(session.identity_map) == 0at the firstprogresscall, the heartbeat + thread-identity off-loop pin, and the cancel-at-the-await arm), and through the real Sources page bytests/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 atK remainingwhilecurrent_filenames 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 withBOR_SYNC_PENDING_COUNT=0rendering the walk numbers for the same fixture (7 remaining, not1), the no-change run answeringfiles_total == 0on 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 predicted2.The STATUS half of the same design (phase 194, task 02) is the machine the page reads:
SyncStatus.phaseisnull(the model-check + clone/pull prelude) →"scan"(set immediately beforeimport_sources, REPLACING the pre-phase= "import"— what runs first there is the pre-scan) →"import"(flipped by the FIRSTprogresscall INSIDE the existing_hookclosure — 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_hookpassed ason_scan_progress) are the scan's own position — reset with the run,0/0on the0rollback 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_filestaysnullthrough the phase (the scan names no file). TheGET /api/sync/statuspayload gainsscan_done/scan_totalADDITIVELY afterfiles_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 thescan→importflip are TRANSITIONS and_sample_rss(force=True), while_scan_hooksamples UNFORCED, so a 10,000-file scan costs one/procread per_RSS_SAMPLE_INTERVAL_Sand not one per file (LOCKED A4). The degenerate empty KB stays"scan"(noprogresscall exists to flip it) and the terminal clears it. Pinned bytests/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 sequencenull → scan → import → overview → summaries → nullwith 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; the0switch reporting0/0scan and the WALK file count) plus the retargeted additive pins intests/unit/test_sync_button.pyandtests/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:
fmtSyncPhaseLabelrendersphase === "scan"as mainScanning…(NEVER a file —current_fileis 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'sN remaininguses, sosetSyncLabel's hidden-toggle and the A4 untruncated placement (button title +#sync-progress, the sole live region) carryScanning… (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 heldK remaining— are pinned on the exact payloads those guards read, served to the realsources.jsbytests/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 (thenull/importfall-through therefore covers ONLY those two), it carries no chunk/image part (the scan reads no file), andscan_total === 0renders the BARE label (the phase-64 no-count-part contract — the empty KB and theBOR_SYNC_PENDING_COUNT=0rollback). 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 — andfmtSyncLabel'sN remainingarithmetic, the terminalcounts_lineand the phase-187fmtSyncPeakClausestay 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 bytests/unit/test_sync_scan_label_ui.py(the branch order, the exact(n/total)shape + itstotal === 0guard, 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 bareScanning…at0/0, every other phase's label byte-identical).
Quick reference
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)