diff --git a/0017/SESSION-0017.1-TRANSCRIPT-2026-05-28T08-52--2026-05-28T09-07.md b/0017/SESSION-0017.1-TRANSCRIPT-2026-05-28T08-52--2026-05-28T09-07.md new file mode 100644 index 0000000..dce6ccf --- /dev/null +++ b/0017/SESSION-0017.1-TRANSCRIPT-2026-05-28T08-52--2026-05-28T09-07.md @@ -0,0 +1,350 @@ +# Session 0017.1 — Subsession transcript + +> Parent: SESSION-0017.0-TRANSCRIPT-…md (not yet written; driver writes +> at end of session 0017.0) +> Date: 2026-05-28 +> Goal: rfc-app v0.19.0 — /docs/sessions browser + flyout nav (roadmap +> item #30) +> Outcome: **Shipped on branch `feature/v0.19.0-docs-sessions-browser` +> at `477f496`. Backend mediates `/api/docs/sessions/{manifest,about, +> /index, /}` with an in-process TTL cache (60s +> manifest, 5min content; both env-tunable + negative-cached on 404). +> Frontend reorganizes `/docs` into a left-flyout hub with the new +> session browser sub-routes; existing DOCS.md content moves to +> `/docs/user-guide`. 313/313 backend tests pass (18 new); frontend +> builds clean. No tag, no pin bump, no benstull mirror push — driver +> takes over after subsession 0017.2 lands.** + +--- + +## Pre-session state + +- `rfc-app` was at `main @ ac3513a`, VERSION `0.18.0`, working tree + clean. Two remotes: + - `origin` → `git.wiggleverse.org/ben.stull/rfc-app` (canonical) + - `benstull` → `git.benstull.org/benstull/rfc-app` (mirror) +- The most recent release on `main` was v0.18.0 (email + webhook + hygiene, roadmap items #18 + #20). +- Existing `/docs` surface was the v0.14.0 single-route DOCS.md + shape: `components/Docs.jsx` rendering markdown via the shared + `MarkdownPreview` (marked + lazy-loaded mermaid). Backend served + the body at `/api/docs` from a disk-cached `DOCS.md` at the repo + root. +- Dispatch settled two design choices before this subsession opened: + the transcript repo is gitea-only (canonical at + `git.wiggleverse.org/wiggleverse/ohm-session-history`, no GitHub + mirror) and the rendering shape is runtime fetch with cache (no + build-time bundling of transcripts). + +--- + +## Turn-by-turn arc + +### Arc 1 — Orientation + +Read `~/git/ohm-infra/SESSION-PROTOCOL.md` (full), `rfc-app/CLAUDE.md`, +the SPEC §21 analytics chapter (DOM patterns + the autocapture +attribute conventions), `backend/app/docs.py` and `backend/app/api.py` +(to see how the existing v0.14.0 `/api/docs` was wired), and +`backend/tests/test_propose_vertical.py` for the `app_with_fake_gitea` +fixture shape that every existing test reuses. + +Noted that `pyproject.toml` doesn't exist in rfc-app (the dispatch +prompt's "bump pyproject.toml" instruction was a non-op — there's a +`backend/requirements.txt` only, and the canonical version sources +are `VERSION` + `frontend/package.json#version` per +SPEC §20.1). + +### Arc 2 — Design choice: per-session listing + +The dispatch prompt named the open design question for the per-session +index page (`/docs/sessions/:nnnn`). Two options: + +1. Extend the manifest endpoint to also list per-session filenames + via the gitea contents API. +2. Add a separate per-session listing endpoint that hits gitea + contents only when the session-index page is visited. + +I went with **option 2** (separate `/api/docs/sessions//index` +endpoint). Reasoning: the manifest stays cheap and small (one fetch, +one cache entry, 60s TTL); per-session listings are fetched on +demand and cached at the content TTL (5min). This means a user who +only browses the flyout's session list (via the manifest) doesn't +pay for `N` extra gitea contents-API calls — only the user who +clicks into a session pays. The endpoint filters the contents-API +response to entries matching the `_TRANSCRIPT_FILENAME_RE` so any +sibling files in a `NNNN/` folder (e.g. an attached `notes.md`) +don't show up in the list. + +### Arc 3 — Design choice: negative caching for 404s + +The dispatch prompt left negative caching for 404s as optional. +**Decided to implement it**: cache 404 results at the content TTL +across the manifest, about, transcript, and session-index endpoints. + +Reasoning: at v0.19.0 ship time, the parallel +ohm-session-history repo restructure (subsession 0017.2) hasn't +shipped yet — the repo is still flat, so the manifest, about, +session-index, and per-transcript fetches will all 404 from gitea +for the first few hours / days. Without negative caching, every +page-view in that window would trigger a fresh gitea request even +though we already know it's 404. With negative caching, repeated +visits to a known-missing transcript only hit gitea once per +5-minute window. Manifest 404 cache means the first OHM user +loading `/docs/sessions/*` doesn't hammer the upstream while +0017.2 is still mid-flight. + +The behavior is exercised by `test_transcript_404_is_cached` in +the new test file. + +### Arc 4 — Backend slice + +Wrote `backend/app/docs_sessions.py` (the fetcher + cache module) +and added four routes to `backend/app/api.py`: + +- `GET /api/docs/sessions/manifest` — returns `sessions.json` as a + JSON object. 404 from gitea → `{}` at HTTP 200 (empty-state + contract, lets the frontend short-circuit without an error + banner). 5xx / timeout → HTTP 502 with + `{detail: {error, detail}}`. +- `GET /api/docs/sessions/about` — returns README.md as + `text/markdown; charset=utf-8`. 404 → HTTP 404; 5xx → 502. +- `GET /api/docs/sessions/{nnnn}/index` — returns + `{"files": [...]}`. The path param `nnnn` must match + `^\d{4}$`; otherwise 400 before any network call. 404 from + gitea (no such folder) → HTTP 404. +- `GET /api/docs/sessions/{nnnn}/{filename}` — returns the + transcript body. Both path params are regex-validated before + the network call (`^\d{4}$` for nnnn, + `^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT(---)?\.md$` + for filename — `--` is optional so legacy renamed- + letter transcripts without timestamps remain reachable). + Returns the body as `text/markdown; charset=utf-8`. + +The fetcher uses `httpx.AsyncClient` with a 5s timeout and no auth +header (the gitea repo is public). The cache is plain +`dict` keyed by URL-path + `time.monotonic()` checks; the +`_lock` is a `threading.Lock` so the cache is safe under +FastAPI's worker thread pool. + +Env knobs: +- `OHM_SESSION_HISTORY_RAW_BASE` (default points at + `wiggleverse/ohm-session-history` on git.wiggleverse.org) +- `OHM_SESSION_HISTORY_CONTENTS_BASE` (default mirrors the raw base + on the contents-API mount) +- `OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC` (default 60) +- `OHM_DOCS_SESSIONS_CONTENT_TTL_SEC` (default 300) + +### Arc 5 — Backend tests + the monkeypatch ordering snag + +Wrote 18 new tests in +`backend/tests/test_docs_sessions_vertical.py`. The tricky bit +took a couple iterations to nail: + +The shared `app_with_fake_gitea` fixture from +`test_propose_vertical.py` monkeypatches `httpx.AsyncClient` via +`monkeypatch.setattr("app.gitea.httpx.AsyncClient", patched)`. +Since `app.gitea.httpx` IS the shared `httpx` module, that +setattr mutates the global `httpx.AsyncClient` attribute. My +docs-sessions test fixture then did the same for +`"app.docs_sessions.httpx.AsyncClient"` — meant to override — +but the first version captured +`real_client_cls = httpx.AsyncClient` at fixture-setup time. +At that point `httpx.AsyncClient` was already the gitea-patched +wrapper, so my "patched" function ended up wrapping the +gitea-patched wrapper, and gitea-bound requests still routed +through the FakeGitea handler (and the FakeGitea returned +404 for session-history URLs, making my test see "manifest +empty"). + +Fix: import the truly-unpatched `AsyncClient` from +`httpx._client` and use that as the base inside my install +closure. The `_UpstreamHandler` also gained a host-marker +filter so it only counts session-history URLs (not the +reconciler's incidental Gitea calls that also hit our handler +via the shared httpx module) in its `.calls` list — that lets +the cache-hit assertions stay exact. + +Test inventory after the fix lands: +- Manifest happy-path / empty-state / 502 +- About happy-path / 404 / 502 +- Transcript happy-path / 404 / 502 +- Path-validation rejection: invalid nnnn, path-traversal, + legacy flat-root filename — all rejected 400 before network +- Session-index happy-path / 404 / invalid-nnnn rejection +- Cache-hit-within-TTL for manifest + transcript +- Negative cache for transcript 404s + +All 18 pass; full backend suite is **313/313**. + +### Arc 6 — Frontend slice + +The dispatch prompt was specific about reusing the existing +markdown renderer (`MarkdownPreview` — marked + mermaid lazy-load), +so the four new sub-route components each render their body +through that same component. No new markdown library. + +Component layout: +- `DocsLayout.jsx` — shell with the flyout nav + `` for + sub-routes. Owns the manifest fetch + the mobile-drawer toggle. +- `DocsUserGuide.jsx` — DOCS.md content (the old `/docs` route). +- `DocsSessionsAbout.jsx` — renders the session-history README. +- `DocsSessionIndex.jsx` — per-session transcript list. Fetches + the manifest (for the title) AND the per-session index + endpoint (for the file list). Treats title-fetch failure as + decorative (just renders the bare `Session NNNN` header) while + the file-list fetch is the load-bearing one. +- `DocsSessionTranscript.jsx` — renders a single transcript. + +`App.jsx` route registration replaced the single +`Route path="/docs"` with: + +``` +/docs → Navigate to /docs/user-guide +/docs/* → DocsWithSidebar (nested Routes inside) + ├── /user-guide + ├── /sessions → Navigate to /sessions/about + ├── /sessions/about + ├── /sessions/:nnnn + └── /sessions/:nnnn/:filename +``` + +Mobile drawer: at viewport ≤ 720px the sidebar is hidden by a +`transform: translateX(-100%)` and revealed via a `☰` button in +the header. A scrim overlay (`.docs-drawer-scrim`) intercepts +outside clicks. The drawer auto-closes on every route change +(via a `useEffect` keyed on `location.pathname`) so a click in +the flyout doesn't strand the user on a drawer-open view. + +Analytics (per SPEC §21): +- Added `EVENTS.DOC_VIEWED` to `frontend/src/lib/analytics.js` + (new constant, taxonomy entry per §21.1's "new events SHOULD + land via a release"). Each sub-route component fires + `track(EVENTS.DOC_VIEWED, { section: '...' })` on mount. +- Every interactive nav element (links, buttons) carries + `aria-label` + `data-amp-track-name` so autocapture rows are + readable per §21.3. The per-row session links also carry + `data-amp-track-session={nnnn}` so the dashboard can + aggregate by session. + +The old `Docs.jsx` is now dead code; deleted in the same commit. + +### Arc 7 — Version bumps + CHANGELOG + push + +Bumped `VERSION` (root) and `frontend/package.json#version` from +`0.18.0` → `0.19.0`. The dispatch prompt named `pyproject.toml` +but there isn't one in this repo (the backend uses a plain +`requirements.txt`); noted in this transcript but no edit +performed. + +`frontend/package-lock.json` still reads `0.15.0` — it has been +drifting since the v0.15.0 release (four minor versions). Not +touched in this release (separate cleanup; see §19.2 candidates). + +CHANGELOG entry was prepended above the v0.18.0 entry verbatim +per the dispatch prompt (header date, body paragraph, upgrade- +steps MAY block, degradation note about the parallel +session-history restructure shipping in subsession 0017.2). + +Three commits on the feature branch, pushed to origin only: +- `39e5770` v0.19.0 backend: /api/docs/sessions/* endpoints + TTL cache +- `822f426` v0.19.0 frontend: /docs/* route tree + flyout nav + sessions browser +- `477f496` Release v0.19.0: /docs nav + on-site sessions browser + +`benstull` mirror untouched per dispatch. + +--- + +## Cut state + +| | | +| --- | --- | +| rfc-app | feature/v0.19.0-docs-sessions-browser @ `477f496`, pushed to `origin` only | +| Tests | pytest backend: **313 passing** (18 new in test_docs_sessions_vertical.py); frontend has no test framework — `npm run build` is green | +| Frontend dev-server smoke check | Skipped (subsession is a sandboxed agent, no browser to drive). The CHANGELOG-named degradation path is structurally exercised by the backend's 404 → frontend's empty-state contract; route-mount errors would have surfaced at `npm run build` | +| What the driver does next | tag v0.19.0 after 0017.2 lands → merge to main → push to origin + benstull mirror → bump ohm-rfc pin → `flotilla deploy ohm-rfc-app` → verify /docs/user-guide + /docs/sessions/about surfaces in the deployed environment | + +--- + +## §19.2 candidates surfaced + +1. **`frontend/package-lock.json` version drift** — what / lock + reads `0.15.0` while `package.json` is now `0.19.0` (the lock + has been stale since the v0.15.0 release). why / it's an + audit-log inconsistency in the npm metadata. when / one-shot + cleanup, low-priority — the lock doesn't affect builds because + the resolved versions are correct, just the top-level `name` + stanza. Fix in the next session that touches frontend deps. + +2. **rfc-app backend has no `pyproject.toml`** — what / the + dispatch prompt assumed a `pyproject.toml` exists; the repo + uses a plain `backend/requirements.txt`. why / standard + Python tooling (uv, hatch, ruff config, mypy config) all + prefer pyproject.toml; absence makes those tools less + ergonomic. when / a session that brings in another tool that + wants the file. Could pair with a §20.1-style canonical-source + audit ("VERSION here, pyproject.toml there — which wins?"). + +3. **`docs_sessions.py`'s private symbols leak into `api.py`** — + what / I called `docs_sessions._is_valid_session_dir` and + `docs_sessions._is_valid_transcript_filename` from the route + layer (underscore-prefixed names). why / the validation is + load-bearing for path-traversal rejection and shouldn't be + re-implemented in the route, but exporting two helpers vs. + one consolidated `validate_path(nnnn, filename) -> bool` would + keep the API cleaner. when / next docs-sessions touch. + +4. **CHANGELOG entry header style drifted** — what / the v0.18.0 + entry header is `## 0.18.0 — 2026-05-28` (no `v` prefix); my + new v0.19.0 entry is `## v0.19.0 — 2026-05-28` (with `v` + prefix) — I followed the dispatch prompt's verbatim block. + why / cosmetic inconsistency in the changelog history. when / + a release that audits the file for consistency could rename + one direction or the other. + +5. **`docs_sessions` cache cannot be invalidated externally** — + what / the in-process cache exposes `reset_cache()` but no + "invalidate this URL" gesture. why / if the operator publishes + a transcript correction and the cache has the old body, they + wait up to 5 minutes for the next reader to see the fix. when / + a session that wires the reconciler's existing periodic refresh + gesture in `cache.py` to also tick this module — or that adds + an admin POST `/api/docs/sessions/refresh` for an explicit + bust. Low priority; the publish gesture is rare. + +--- + +## Notes for the driver + +- **Path-traversal corner**: FastAPI / Starlette normalizes `..` + in the URL path before routing, so `/api/docs/sessions/0001/../etc/passwd` + resolves to a path that doesn't match the + `/api/docs/sessions/{nnnn}/{filename}` route. The defense-in-depth + is still in place (regex validates `nnnn` AND `filename` before + any network call), but the test for path traversal had to use + `etc%2Fpasswd` (URL-encoded slash) to actually reach the + handler — the bare `../etc/passwd` was 404'd by the router + itself. + +- **TestClient + lifespan**: the backend tests use + `with TestClient(app) as client:` which triggers the FastAPI + lifespan. That starts the reconciler, which makes Gitea calls + that ALSO hit our patched `httpx.AsyncClient` (since httpx is a + shared module — see Arc 5). The `_UpstreamHandler` filters its + `.calls` list to session-history URLs to keep the cache-hit + assertions exact. Reconciler hits return 404 (no matching key + in the responses dict), which the reconciler logs as a warning + and proceeds — harmless for our test surface. + +- **Frontend dev-server check not run**: the dispatch prompt + named this as optional. The subsession is a sandboxed agent + without a browser to drive; the build's success and the route + registration's symmetry with the existing `/philosophy` chrome + is the closest analog I could verify. Recommend the driver run + `npm run dev` against the feature branch before merging to + spot-check the flyout's render + drawer toggle on a real + viewport. + +- **No tag, no pin, no mirror push** — per dispatch. Driver + handles tag → ohm-rfc pin bump → flotilla deploy after 0017.2 + lands.