docs/architecture.md has the design; README.md has usage. This file is the
working context that isn't in either — conventions and gotchas learned
the hard way.
make test lint typecheckmust pass. pylint must be 10.00/10 with zero warnings — any message fails the gate. (.pylintrc: max-branches=25; C0114/C0115/C0116/W0613/R0903 disabled.)- black is not in CI, but keep
ietf_llm/black-clean:.venv/bin/black --check --target-version py313 <changed files>before committing, or a latermake tidywill churn them in an unrelated commit.make tidyformats onlyietf_llm/, nottests/— do not run black over the test files; they use a compact style black fights and it isn't enforced there. - Do not edit files
make updateoverwrites from mnot/pyproject-tmpl (the authoritative list isUPDATE_MAKEFILES+UPDATE_FILESinMakefile.pyproject):Makefile.pyproject,Makefile.venv,mypy.ini,.pylintrc,.isort.cfg,.github/workflows/publish.yml. Each carries a banner saying so. .github/workflows/test.yml,.gitignoreand.editorconfigare managed except inside# === pyproject-tmpl: start "<name>" ===regions, whose contents are spliced back in on update. Project-specific CI steps go in a region, never outside one.- The top-level
Makefileis ours — no banner, not in either update list, and already wherePROJECTand the project'stesttarget live. Add project-specific targets there rather than reaching for a bare script.
- Subject:
Added:/Changed:/Fixed:/Removed:+ lowercase summary. A pure chore (e.g. applying black) gets no prefix — it is not a changelog entry (justApply black formatting). - End the body with a
Co-Authored-By: <model> <noreply@anthropic.com>trailer naming the model you are actually running as — use your real current version, not a string copied from here or an old commit. - Gotcha:
git commit -m "$(cat <<'EOF' … EOF)"breaks on an apostrophe in the body (corpus's,typo'd,WG's) — shell reports "unexpected EOF". Reword to avoid apostrophes, or commit another way. - One feature per commit; PR only when asked.
Most items point to a section below for the detail — this is the scan, not the spec. Skip an item only when you can say why it doesn't apply.
- Reader-side vs write-side? Does this change what gather writes? If so existing caches are stale until re-gathered (cloud: until published) — flag it in the PR. See the section below.
- Local backend. Default
local(filesystem) path still works and is unchanged in behaviour? This is the path almost everyone runs. - Cloud backend. Touch
store// S3 / the control plane? Re-check the CAS pointer flip, the gather lease, thefleet/slotssemaphore, and the accelerator caches. See "Conventions that are load-bearing". - Concurrency. Cloud is multi-host: any new shared state needs a
CAS/lease story (no SQL, no transaction —
get+ conditionalput). Local is single-process — don't assume that on cloud. - Efficiency. Too much time waiting for a result (or a gather) will lose us users.
- Source burden. We need to be friendly to our back-end services: datatracker, GitHub, etc.
- Read-only boundary. Read tools stay read-only and offline. New
network/write code belongs only on the gather path (or the gather tools
start_gather/gather_status, registered only when gather is enabled). Don't materialise a cache from a read tool. - NotebookLM / export use case.
ietf_llm/export.pymirrors the.txt/.mdfiles under a corpus'sfiles/(bundled by year/repo) for NotebookLM. Changing what gather writes there changes the export — it's write-side. And the serve path must stay torch-free (tests/test_serve_torch_free.py): remoteopenai-embed/embeddings (embeddings/models.py) and HTTP transport must keep working without importing torch. - Operability. New failure mode that needs to be diagnosable in the cloud
deployment? Wire it into what exists — no external logging/metrics libs, all
hand-rolled: structured logs (
IETF_LLM_LOG_FORMAT=json,log.log), Prometheus RED metrics atGET /metrics(serve_metrics.py),GET /healthreadiness, per-gather egress ingather-metrics.json(net/http_metrics.py), and opt-in request telemetry (IETF_LLM_DEBUG_LOG,get_session_log). - Dead code sweep. Remove anything this change orphans — old branches, now-unused helpers, superseded config.
- Docs.
README.md(usage) anddocs/architecture.md(design) still accurate? Update them in the same PR. - Routing brain + tool docstrings.
ietf_llm/data/mcp-instructions.mdis the routing brain, served to clients as the MCPinstructionsfield. Update it and the affected tool docstrings whenever behaviour changes. (The skills vendored underietf_llm/data/skills/<name>/are separate installed Agent Skills, not the routing brain — and are vendored, so edit them upstream in mnot/ietf-skill and re-runmake vendor-skills; CI runsmake vendor-skills-check, so drift from the pin fails the build.) - The gate.
make test lint typecheckclean (pylint 10.00/10), andietf_llm/is black-clean. See "The gate" above.
- Changes to how tools render (overview,
digest/query, search, read_topic) are reader-side — existing caches benefit immediately. - Changes to what gather writes (per-thread files /
elide_quotes,group.md,documents.json, charter text, digests) are write-side — existing caches keep the old content until the nextietf-llm <wg>. Flag this when proposing such a change.
- Unit tests stub the network and embedding model. For real behaviour,
call the
tool_*functions directly against the gathered caches in~/.cache/ietf-llm/(httpbis,tls,aipref, …) — that is how most of the MCP work here was validated, and it catches things fixtures miss. - Test against real writer output, not only hand-built fixtures. A
digest-rendering bug slipped because
query_digestwas tested against synthetic tables that didn't match what the writer emits. The durable guard is a writer→reader round-trip: drivegenerate_digests, then read its bytes back through the tool. _build_with_stub(wg, isolated_home)(two args) builds an embedding index for a seeded cache. The stub model scores every chunk equally (search returns all of them — usek/limits to shape output).tests/test_rfc_corpus_spotcheck.pyreads the real cache, which every other test is forbidden to do. It samples the installed RFC corpus against the RFC Editor's own text — a property no fixture can stand in for — and skips when the corpus or the_rfc/textmirror is absent, so CI never sees it.IETF_LLM_RFC_SPOTCHECK=0skips it anyway. Keep its checks ones that cannot fail spuriously: "every returned line exists in the source" holds regardless of where a section is thought to begin, whereas anything that reconstructs section extents by matching text will not — older RFCs repeat lines, and three attempts at that reported failures that were the metric.
ietf_llm/data/mcp-instructions.mdis served to clients as the MCPinstructionsfield — it's the routing brain. Update it (and the tool docstrings) whenever behaviour changes. (The installed Agent Skills live separately underietf_llm/data/skills/<name>/SKILL.md.)- The MCP server is read-only and never touches the network; gather is
the only writer. Keep that boundary. (
get_wg_file_cache_dircreates the dir — use a read-only existence check at the tool boundary so a mistyped corpus name doesn't materialise a junk cache.)- The exceptions, all behind one gate.
freshness.gather_enabledis the project's sanctioned networked-read gate: a networked read belongs on the same side of the line as the networked writer, so both default on for stdio and off for the HTTP replica, which keeps that deployment read-only. Behind it today: the gather tools (start_gather/gather_status), which let MCP initiate a gather, so they write and reach the network; the live Datatracker lookups (meeting_schedule,draft_status,overview(live=True)); the seed catalog refresh inlist_corpora; andget_rfc, which revalidates the_rfc/mirror on a stale miss (docs/architecture.md, "The networked read exception"). Adding a new one is a real design decision — argue it against that section — but it is a sanctioned pattern, not a boundary breach. Everything else stays read-only and offline. The gather tools default on for a local stdio server (that user can already runietf-llmagainst the same cache) and off for the shared HTTP deployment (keeping it read-only);IETF_LLM_ENABLE_GATHERoverrides either way, and an immutable index mount also forces the default off. When gather is disabled the tools aren't registered, so the HTTP surface keeps the boundary intact.
- The exceptions, all behind one gate.
- The cache is reached through a
CorpusStoreseam (ietf_llm/store/): read tools resolve a corpus's files dir viaget_corpus_store().local_cache_dir(the_files_dir/_corpus_existsboundary inmcp, which also keeps the read-only existence check above), and a gather publishes viastore.publish. The defaultlocalbackend is today's filesystem — no behaviour change. The opt-incloudbackend (IETF_LLM_STORE_BACKEND=cloud,IETF_LLM_STORE_URL=s3://…) is object-store only: one S3-compatible bucket holds both the immutable version content (the blob plane) and the control plane — aKvControlPlaneover theKvStorecompare-and-swap seam (get+ a conditionalput; no SQL, no transaction).S3KvStoreandS3BlobStoreshare oneS3Bucket. Per-corpus control lives undercorpora/<name>/{pointer,lease, status}, content (and its manifest) undercorpora/<name>/versions/<version>/, and the one cross-corpora key — the gather-slot semaphore — atfleet/slots. The same bucket also holds the gather accelerator caches (gather/sources/cache_sync.py), hydrated before a gather and persisted after so an ephemeral host doesn't re-hit rate-limited upstreams:.http-cache.jsonsharded per corpus atcorpora/<name>/gather-cache/(lease-serialised, plain RMW), the shared GitHub/datatracker identity maps atfleet/gather-cache/(CAS-merge), and the effort catalog (_catalog/) atfleet/catalog/(a shared fleet singleton, one key per file, last-writer-wins). These are gather-only (the read path never touches them) and best-effort._rfc/is deferred (non-rate-limited CDN mirror). See issue #82. publish is the explicit write→read handoff (a compare-and-swap pointer flip), and the gather lease is cross-host. So on the cloud backend the reader-side vs write-side line above is mediated by publish: a re-gather is not visible to readers until it publishes a new version. Seedocs/architecture.md("The storage seam").