phase: 167_api_payload_diet
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 14s

All gates green. Final report:

**Verified (phase 167 final pass — all 6 tasks already in `complete/`)**
- Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte.
- Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`.

**Gates (all 100% green)**
- `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line).

**Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines):
- Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed.
- AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree).
- Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere.

**Notable decisions / deviations**
- `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's.
- A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened.
- Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run.

**Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
This commit is contained in:
2026-10-08 21:23:49 -04:00
parent ab9391d019
commit 2ac37455f0
73 changed files with 7313 additions and 228 deletions
+108 -2
View File
@@ -159,6 +159,30 @@ document's date from the document viewer. The date also feeds the small
recency boost in retrieval ranking (`BOR_RECENCY_BOOST`). Admin-only —
anonymous visitors see a sign-in gate instead.
The tree payload is bounded (phase 167): `GET /api/docs/tree` builds only
`depth` levels (default **1** — the sources plus one level, the page's first
paint; `depth=2..8` builds deeper, anything else is a 422) instead of the old
~2 MB full recursive dump. A node whose children were not built carries
`"truncated": true` and ships with its **recursive** document count, updated
date, description and pending marker (`children: []`); untruncated nodes are
byte-for-byte what they were before. The three stat cards read the
server-computed top-level `totals` (`documents`, `chunks`, `last_indexed`) so
they stay honest whatever depth was requested. Responses also ship gzipped
(see *Caching & deploys*). Deeper levels load on demand through
`GET /api/docs/tree/node?source=X&path=Y&depth=N` (admin-only, phase 167):
one node — the source root for `path=""` — answered in exactly the shape the
full tree produces for it (unknown source or folder → 404 `unknown node`),
built from the same single catalogue query. The RAG view fetches each drilled
level once and caches it (re-opening a folder costs no network), showing one
*Loading…* row while a level arrives and a *Couldn't load … — Retry* line on
failure with the current level staying rendered; a folder that vanished since
it was listed (the node 404) resets the view to the top level. The
breadcrumb, the bounded scroll panel, and the description editors behave
exactly as before. All three catalogue reads
(`/api/docs`, `/api/docs/tree`, `/api/docs/tree/node`) also carry a weak
`ETag` + `Cache-Control: private, must-revalidate`, so reloading a page you
just looked at costs a bodiless 304 (see *Caching & deploys*).
### Git sources (`/git-sources.html`)
The admin-managed source registry: add or remove git repositories, upload
@@ -942,8 +966,13 @@ uploaded archives):
## Caching & deploys
A deploy is a commit — and the browser must see it **without a hard refresh**.
One Starlette middleware (`app/core/caching.py`) applies the rule at the
transport layer:
The first three bullets below are that A19 rule, applied at the transport layer
by `app/core/caching.py`; the last two are phase 167 — gzip from
`app/core/compression.py` and per-route validators from `app/core/http_cache.py`.
Outermost to innermost, the stack is security-headers → gzip (only when
`BOR_COMPRESS_MIN_BYTES > 0`) → the byte-budget logger → caching → routes, so
security headers land on every response and `bytes=` in the budget line is the
route's own, uncompressed payload (see *Operational notes*):
- **HTML pages** ship `Cache-Control: no-cache`, **no `etag`**, **no
`last-modified`** — each visit always gets a fresh 200 body.
@@ -954,6 +983,26 @@ transport layer:
`HEAD` — so every commit/deploy flips the token and the versioned asset
URLs change with it. A checkout without `.git` falls back to a stable
content hash of the `frontend/` tree.
- **Responses are gzipped (phase 167).** Every HTTP response except the
`POST /api/chat` SSE stream goes through selective gzip
(`app/core/compression.py`): when the client accepts gzip and the body
is at least `BOR_COMPRESS_MIN_BYTES` (default `860`; `0` = the feature
is OFF), KB JSON ships ~8-12x smaller — the fix for the ~2 MB
`/api/docs/tree` load. The SSE stream is skipped by EXACT path (A15:
frames keep arriving one by one, uncompressed), small responses stay
identity, and an already-encoded body is never double-encoded, so a
fronting nginx may gzip too.
- **Catalogue reads revalidate (phase 167).** `GET /api/docs`,
`GET /api/docs/tree` and `GET /api/docs/tree/node` ship a content-exact
weak `ETag` (SHA-256 of the served body, `app/core/http_cache.py`) with
`Cache-Control: private, must-revalidate` — a repeat load costs a 304
with no body, and a sync or an admin edit (folder summary, document
date) changes the body, hence the tag, so it is seen on the next load.
The tag is deliberately NOT `sources_meta.version` (bumping that marks
saved chats stale, and those edits never bump it). This is a scoped
extension of the rule above: validators live on these three read-only
routes only — HTML pages keep publishing none, and the SSE stream and
every mutating route are untouched.
No CDN, no new services, no build-step change: the middleware rewrites the
asset references of the known pages in flight.
@@ -962,6 +1011,61 @@ asset references of the known pages in flight.
> page visit, so the browser revalidates the HTML once and starts requesting
> the versioned assets; every commit after that is picked up automatically.
### Operational notes: the response byte budget (phase 167)
Every `/api/*` response writes ONE parseable line, so "which endpoint is
heavy, right now?" is answerable from the app's own log:
```
2026-10-08 07:40:57 INFO app.response_budget :: api method=GET path=/api/docs/tree status=200 bytes=8474 enc=gzip duration_ms=12
```
```bash
grep ' :: api ' server.log # every measured response
grep ' :: api ' server.log | grep 'bytes=[0-9]\{6,\}' # the six-figure ones
```
- **`bytes=`** is what the ROUTE produced (uncompressed), **`enc=`** is what
went on the wire (`gzip` or `identity`) — "how fat is it, and how fat did
the client see it". A revalidated `304` logs `bytes=0` on purpose: that is
the win you want to see.
- **Level:** `INFO` at or over `BOR_LOG_LARGE_RESPONSE_BYTES` (default
`65536`), `DEBUG` below it, so the default log stays readable.
`BOR_LOG_LARGE_RESPONSE_BYTES=0` logs EVERY `/api/*` response at INFO —
the mode for a one-off investigation. A negative value refuses startup.
- **`stream=1`** marks the SSE turn (`POST /api/chat`, A15): `bytes` is the
streamed total and `duration_ms` the stream's life. One line per stream,
written when it ends — including a stream the client cancels (with the
bytes that got out). The middleware is pure ASGI and never buffers.
**Measuring the whole API** (the audit behind the fix):
```bash
# the corpus that matches production's order of magnitude
# (destructive to the local DB — see the restore-test-db skill)
uv run uvicorn app.main:app --port 8000
uv run python -m scripts.api_payload_audit --base-url http://localhost:8000
```
It signs in with `BOR_ADMIN_PASSWORD` (or `--password`; the single-admin
login has no username), measures every candidate read route in BOTH
`identity` and `gzip` forms plus one conditional revalidation, prints the
corpus it measured, and marks anything over `--budget` (default `262144`)
with `LOOK`. It is **read-only** (safe against the deployed app) and always
exits 0 when it got its numbers — it is a report, not a gate (LOCKED A5:
nothing in this phase rejects a large payload). Exit `2` means the app was
unreachable or the login was rejected. The measured before/after tables and
the verdict per endpoint live in the phase record — `167_api_payload_diet/AUDIT.md`
under `.agents/phases/`.
> **Deploy note (compression):** the app gzips its own responses, so nginx
> `gzip on` is **optional** — leaving it off ships the same bytes, and
> leaving it on is fine too: an already-encoded body is never double-encoded
> by either side. Passing the client's header through untouched
> (`proxy_set_header Accept-Encoding $http_accept_encoding;`, or simply not
> overriding it) is correct; unsetting it would hand every client
> uncompressed JSON.
---
## Configuration reference
@@ -982,6 +1086,8 @@ to `.env` and adjust.
| `BOR_ENVIRONMENT` | `development` | `development` or `production` (the prod compose profile sets `production`) |
| `BOR_LOG_LEVEL` | `INFO` | App log level |
| `BOR_STATIC_DIR` | `frontend` | Where static files are served from (the container image sets `/app/static`) |
| `BOR_COMPRESS_MIN_BYTES` | `860` | Selective-gzip floor (phase 167): responses ≥ this size compress when the client accepts gzip; `0` = OFF (no middleware), negative refuses startup. `POST /api/chat` (SSE) is never compressed |
| `BOR_LOG_LARGE_RESPONSE_BYTES` | `65536` | INFO/DEBUG cut for the per-response `api method=… bytes=… enc=…` byte-budget line (phase 167, `grep ' :: api '`); `0` = log every `/api/*` response at INFO, negative refuses startup. See *Operational notes* |
### Database & concurrency