diff --git a/planning/reports/M1-BASELINE-REPORT.md b/planning/reports/M1-BASELINE-REPORT.md index dd12a4a..6df4a9b 100644 --- a/planning/reports/M1-BASELINE-REPORT.md +++ b/planning/reports/M1-BASELINE-REPORT.md @@ -42,7 +42,7 @@ vendored font files and their licences. No upstream file was deleted. | Change | Files | | --- | --- | -| Fork lineage and licence provenance | merge commit `46d34dc`, `PROVENANCE.md` | +| Fork lineage and licence provenance | merge commit `7f182a8`, `PROVENANCE.md` | | Tokenizer no longer downloads | `backend/app/context/encoding.py`, `backend/app/context/vendor/cl100k_base.tiktoken`, `backend/app/context/builder.py` | | Fonts self-hosted | `frontend/index.html`, `frontend/src/index.css`, `frontend/src/styles/fonts.css`, `frontend/public/fonts/*`, `frontend/tools/vendor_fonts.py`, `.gitattributes` | | CSP narrowed to same-origin | `backend/app/main.py` | diff --git a/planning/reports/M1-IMPLEMENTATION-REPORT.md b/planning/reports/M1-IMPLEMENTATION-REPORT.md new file mode 100644 index 0000000..8be22e0 --- /dev/null +++ b/planning/reports/M1-IMPLEMENTATION-REPORT.md @@ -0,0 +1,995 @@ +# M1 — Implementation Review Report + +**Date:** 2026-09-02 +**Milestone:** M1, *Establish Production Fork and Offline Baseline* +**Audience:** the architecture/design reviewer deciding whether to prepare M2 +**Companion:** `planning/reports/M1-BASELINE-REPORT.md` holds the raw run logs +and packet-capture output this report summarises. Where the two differ in +detail, that one is the primary record. + +Hostnames and LAN addresses are **placeholders** (`inference.lan`, +`192.168.0.0/24`). The real ones are in the workspace's untracked notes, never +in this repository. Packet counts, digests, timings and command output are +verbatim. + +--- + +# A. Executive Result + +**Overall M1 result: PASS.** + +- **Is the application a playable production baseline?** Yes. A campaign can be + created, played, persisted, restarted and resumed from the inherited browser + UI, with outbound Internet blocked. This was exercised end to end, not + inferred: three separate runs, plus a by-hand browser check in which the + campaign list rendered and a campaign was opened and read. +- **Were both Ollama paths demonstrated?** Yes, both at runtime. Same-host + loopback in an air-gapped container; trusted-LAN against Ollama 0.33.0 on a + **second physical machine**, over TLS, with the storyteller's default route + deleted so the LAN was reachable and the Internet was not. +- **Proceed to M2?** Yes. +- **Blockers before M2?** **None.** Section M lists debt and inherited + behaviour, none of which blocks M2 and most of which M2 removes by design. + +One qualification worth the reviewer's attention: M1 required a code change +that was **not in its planned scope** — outbound TLS verification (§G, §J). +Without it the trusted-LAN path did not work at all against a realistic host, +so it was in M1's critical path even though the milestone text never mentions +it. + +--- + +# B. Repository / Provenance + +| | | +| --- | --- | +| Branch | `m1-production-baseline` | +| HEAD | `c1a73b3d77e48491196e8887ee5abc2f818af05e` | +| HEAD signature | good (`%G? = G`) | +| Working tree | clean apart from one documentation correction, below | + +### M1 commits + +| Commit | Sig | Parents | Subject | +| --- | --- | --- | --- | +| `c1a73b3` | G | `7f182a8` | M1: make the first story turn work with no Internet | +| `7f182a8` | G | `717670a`, `d72f7c1` | Fork AI-DnD at d72f7c1 as the production base | + +`7f182a8` is the fork import: a merge with **two parents** — the planning +package's own history (`717670a`) and upstream AI-DnD (`d72f7c1`). `c1a73b3` +is all of the M1 work. + +### Upstream + +| | | +| --- | --- | +| Project | AI-DnD, | +| Pinned commit | `d72f7c1bda0f34fccd84afb7a25c34eb01c901de` | +| Subject | "Stop paying twice for a block a retry can still throw away" | +| Position | tip of `upstream/main` when the fork was taken, 1 Sep 2026 | +| Substituted? | **No.** The pinned commit was fetched and verified before use. | + +### How provenance is preserved + +Upstream history is *in* this repository rather than copied out of it, so the +claim is checkable rather than asserted: + +```console +$ git cat-file -t d72f7c1bda0f34fccd84afb7a25c34eb01c901de +commit +$ git merge-base --is-ancestor d72f7c1bda0f34fccd84afb7a25c34eb01c901de HEAD && echo yes +yes +$ git rev-list --count d72f7c1bda0f34fccd84afb7a25c34eb01c901de +172 +``` + +All 172 upstream commits are reachable, not a squashed snapshot. Upstream paths +are unchanged (`backend/`, `frontend/`, `docs/`, …), so a later upstream commit +can still be fetched and cherry-picked against matching files. + +### Licence and provenance files + +| File | State | +| --- | --- | +| `LICENSE` | **unmodified.** `git diff d72f7c1 HEAD -- LICENSE` is empty. MIT, © 2026 Parth Thakkar. | +| `PROVENANCE.md` | **new.** Upstream commit, licence terms, re-verification commands, both vendored assets with sources and digests, and the full list of what M1 changed. | +| `frontend/public/fonts/OFL-*.txt` | **new.** SIL OFL 1.1 text for each vendored family, shipped beside the fonts as the licence requires. | + +### Final `git status` + +Clean except for one file, which is a **documentation correction, not an +implementation change**: + +```text + M planning/reports/M1-BASELINE-REPORT.md +``` + +Signing the fork-import commit re-hashed it from `46d34dc` to `7f182a8`, and +the baseline report's change table still cited the pre-signing hash. That one +line now cites `7f182a8`. It is uncommitted and needs a signed commit; §N has +the command. No other file differs from `c1a73b3`. + +--- + +# C. What Changed + +`c1a73b3` — **35 files changed, 102 102 insertions, 23 deletions.** The +insertion count is dominated by two vendored assets: the tokenizer table +(100 256 lines) and three OFL licence texts (279 lines). Excluding vendored +data and the baseline report, M1 is **1 101 inserted lines against 23 deleted** +— code, tests, generated CSS and documentation. Of those, roughly 390 are new +application code and tests, 352 are documentation, and the rest is the +generated font CSS, the lockfile and the vendoring script. + +```text + .gitattributes | 4 + + .gitignore | 4 + + DEVELOPMENT.md | 246 + + Dockerfile | 5 + + PROVENANCE.md | 106 + + backend/app/context/builder.py | 9 +- + backend/app/context/encoding.py | 90 + + backend/app/context/vendor/cl100k_base.tiktoken | 100256 +++++++++++++++ + backend/app/main.py | 29 +- + backend/app/providers/openai_compatible.py | 14 +- + backend/app/routers/settings.py | 4 +- + backend/app/tlstrust.py | 47 + + backend/requirements.lock | 57 + + backend/requirements.txt | 4 + + backend/tests/test_offline_assets.py | 160 + + backend/tests/test_tls_trust.py | 93 + + docker-compose.yml | 7 +- + frontend/index.html | 10 +- + frontend/public/fonts/OFL-*.txt | 279 + + frontend/public/fonts/*.woff2 | Bin 0 -> 351196 bytes + frontend/src/index.css | 1 + + frontend/src/styles/fonts.css | 90 + + frontend/tools/vendor_fonts.py | 137 + + planning/reports/M1-BASELINE-REPORT.md | 466 + + start.ps1 | 2 +- + start.sh | 5 +- +``` + +**Nothing inherited was deleted.** The 23 deletions are lines replaced in +place, not features removed. Removal is M2's job. + +### Tokenizer + +| File | Origin | What changed | Why M1 needed it | +| --- | --- | --- | --- | +| `backend/app/context/vendor/cl100k_base.tiktoken` | **new** (vendored data) | The `cl100k_base` BPE table, 1.7 MB, SHA-256 `223921b7…65b2a7`. | The download this replaces is what killed the first story turn offline. | +| `backend/app/context/encoding.py` | **new** | Builds a `tiktoken.Encoding` from the vendored table, verifying its SHA-256 against the digest `tiktoken` itself pins for the source URL. | Removes the network from the code path entirely, rather than relying on a warm cache or an env var. | +| `backend/app/context/builder.py` | **inherited, modified** | `_encoding()` delegates to the new module; its `functools.lru_cache` moves there (one cache instead of two). | Single call site; every turn goes through it. | + +### Fonts and browser assets + +| File | Origin | What changed | Why M1 needed it | +| --- | --- | --- | --- | +| `frontend/public/fonts/*.woff2` | **new** (vendored data) | Cinzel, Crimson Pro, Inter as variable fonts, Latin + Latin-Ext, 343 KiB total. | The SPA fetched these from Google on every page load. | +| `frontend/public/fonts/OFL-*.txt` | **new** | SIL OFL 1.1 licence text per family. | Required by the OFL for redistribution. | +| `frontend/src/styles/fonts.css` | **new, generated** | `@font-face` declarations pointing at `/fonts/…`. | Replaces the Google stylesheet. | +| `frontend/tools/vendor_fonts.py` | **new** | Regenerates both of the above from the Google Fonts API. | Keeps the vendored bytes reproducible instead of opaque. | +| `frontend/index.html` | **inherited, modified** | Two `preconnect` hints and the Google stylesheet `` removed, replaced by a comment pointing at the script. | The actual remote-asset request. | +| `frontend/src/index.css` | **inherited, modified** | One `@import` for `fonts.css`, first in a load-bearing cascade order. | Faces must be declared before `tokens.css` names the families. | +| `.gitattributes` | **inherited, modified** | `*.woff2`/`*.woff` marked binary. | Prevents line-ending normalisation corrupting a font. | + +### Security headers and listener + +| File | Origin | What changed | Why M1 needed it | +| --- | --- | --- | --- | +| `backend/app/main.py` | **inherited, modified** | CSP: `fonts.googleapis.com` and `fonts.gstatic.com` dropped, `font-src 'self'` added, plus `object-src 'none'`, `base-uri 'none'`, `form-action 'self'`. Separately, `mimetypes.add_type("font/woff2", …)` so the fonts are served with their real type instead of `application/octet-stream`. | M1's "tighten the CSP so runtime assets are local". The policy now names no remote origin at all. | +| `start.sh`, `start.ps1` | **inherited, modified** | `--host 127.0.0.1` stated explicitly rather than inherited from uvicorn's default. | A02 is a requirement, not a default worth inheriting silently. | +| `docker-compose.yml` | **inherited, modified** | Publishes `127.0.0.1:8000:8000` instead of `8000:8000`. | `8000:8000` publishes on every host interface — the storyteller on the LAN, unauthenticated. | +| `Dockerfile` | **inherited, modified** | Comment only, explaining why the in-container listener is `0.0.0.0` and that the port must be published to loopback. | The 0.0.0.0 bind reads like a contradiction of A02 without it. | + +### Outbound TLS *(unplanned; see §G and §J)* + +| File | Origin | What changed | Why M1 needed it | +| --- | --- | --- | --- | +| `backend/app/tlstrust.py` | **new** | One cached `SSLContext` unioning the platform CA store with certifi's bundle. | Without it the trusted-LAN path fails against any host with a locally-issued certificate. | +| `backend/app/providers/openai_compatible.py` | **inherited, modified** | Three `httpx.AsyncClient(…)` calls take `verify=tlstrust.ssl_context()`. | Turns, completions and embeddings. | +| `backend/app/routers/settings.py` | **inherited, modified** | The connection-test client takes the same context. | Otherwise **Test connection** disagrees with what a turn would do. | +| `backend/requirements.txt` | **inherited, modified** | `certifi` declared. | It is now imported by name rather than arriving via httpx. | + +### Environment, tests, documentation + +| File | Origin | What changed | Why M1 needed it | +| --- | --- | --- | --- | +| `backend/requirements.lock` | **new** | The exact tested closure, 40 pins including transitive and dev dependencies. | M1's "reproducible dev/test environment". `requirements.txt` keeps the ranges. | +| `backend/tests/test_offline_assets.py` | **new** | 10 tests: vendored-table integrity, tokenizer opens no socket, golden token counts, CSP names no remote origin, no remote URL in markup/CSS, every declared font file exists, built SPA clean. | Both fixed bugs were invisible on a machine that had been online once. | +| `backend/tests/test_tls_trust.py` | **new** | 6 tests: verification not weakened, context cached, certifi roots survive the union, and an AST walk asserting every `httpx.AsyncClient` passes `verify=` — including that no third module starts making requests. | Guards the §G change in both directions. | +| `DEVELOPMENT.md` | **new** | Setup, run modes, same-host and trusted-LAN Ollama (including the private-CA case), test commands, the offline re-verification procedure, and what M1 deliberately left alone. | M1's environment/configuration documentation. | +| `.gitignore` | **inherited, modified** | `/phase0b/` ignored. | Phase 0B research scratch — virtualenvs, databases, downloaded models. | + +--- + +# D. User-Visible M1 Capability + +Everything below was done through the running application. The browser step was +confirmed by hand by the maintainer: the campaign list rendered at +`http://127.0.0.1:8000`, a campaign was clicked, and the adventure and its +transcript displayed. + +### What a user can do now + +1. **Start the application locally** — `./start.sh` for development, or a built + SPA served by FastAPI on `127.0.0.1:8000` for production. Both bind loopback. +2. **Configure a model** — Settings → endpoint URL, model, API mode, output and + context budgets. **Test connection** lists the endpoint's models. +3. **Create a campaign** and give it a persona. +4. **Generate narration** — Do / Say / Story / Continue, streamed over SSE. +5. **Persist and resume** — campaigns survive a clean restart with transcript + and head position intact; the adventures list reopens them. +6. **Use same-host Ollama** — `http://127.0.0.1:11434/v1`, the default endpoint, + with no Internet at any point. +7. **Use trusted-LAN Ollama** — an explicitly configured endpoint on another + machine, `http://…:11434/v1` or `https://…/v1` with a private CA, while the + storyteller UI/API stays on loopback. +8. **Recover from a failed model call** — a clear error, accepted history + untouched, and play continues. + +The inherited surface also still works and is reachable from the nav: Home, +Adventures, Scenarios (with the stat-schema and NPC editors), Scripts (the +CodeMirror JavaScript editor), Settings, plus AI Chat and the Visitors +dashboard, which local installs always see. + +### First-run step worth knowing + +`Settings.model` defaults to `""`, so **a user must pick a model before the +first turn**; the endpoint already defaults to `http://localhost:11434/v1`. +Nothing tells them this on the way in. Cosmetic in M1, a real onboarding +question for M8. + +### Inherited limitations still visible, deferred by design + +| What the user sees | Milestone that addresses it | +| --- | --- | +| **Undo deletes turns and there is no Redo.** Verified in code, not assumed: `POST /adventures/{id}/undo` deletes the trailing AI action and its player action and prunes covering memories; no redo endpoint or control exists anywhere in the backend or SPA. | M3 | +| Account, hosted and cloud-provider surfaces exist in the tree; the endpoint field accepts any URL. | M2 | +| RPG world-state machinery — stats, flags, milestones, cast — with relative-delta proposals. | M5 | +| JavaScript campaign scripting, sandboxed in QuickJS. | M2 removes it | +| The Visitors analytics dashboard (local counters, two SQLite tables, no outbound request). | M2 | +| Memory bank and auto-summarisation are **off per adventure by default**, even when an embedding model is configured globally. | M6 | +| No named Save Points; no imported knowledge; UI is still AI-DnD's. | M4, M7, M8 | + +--- + +# E. Acceptance-Test Results + +Every row is runtime behaviour observed in a running system. Where a check was +source-level, the row says so and does not claim PASS on that basis. + +| ID | Result | Procedure | Evidence | Caveat | +| --- | --- | --- | --- | --- | +| **A01** Start application offline | **PASS** | Run 1 (§F). `--internal` Docker network; app started; campaign created; six turns. | Isolation proven first — `1.1.1.1:443` → `Network is unreachable`, every name → `gaierror`. Six turns generated and persisted. Whole browser asset graph fetched over loopback, all 200. | The UI render was confirmed in a browser on the *native* run, which had Internet (§F). | +| **A02** Storyteller loopback default | **PASS** | Listener enumerated from `/proc/net/tcp` in each container; `ss -ltnp` on the native run; a TCP connect to this host's LAN address. | Runs 1, 2, 3 all show `LISTEN 127.0.0.1:8000` and nothing else. Native run: `connect 192.168.0.10:8000 → Connection refused`. `docker-compose.yml` publishes `127.0.0.1:8000:8000`. | In Docker the *in-container* listener is `0.0.0.0`; loopback-only exposure comes from the published port. A hand-run `docker run -p 8000:8000` would defeat it — §M. | +| **A03** No cloud API key | **PASS** | `api_key: ""` in every run; connection test, turns, summaries and embeddings all exercised. | `"api_key_set": false` in each run's settings dump; all operations succeeded. | — | +| **A04** Campaign survives restart | **PASS** | Run 1: 6 turns → `docker restart` → re-read. Run 2: same, plus a further turn. | Run 1: 12 actions, head 27, identical transcript before and after. Run 2: digest `f24caf86a744ab36` identical across restart; endpoint, embedding model and memories preserved; next turn produced in 6.4 s. | — | +| **A05** Failed model call does not corrupt story | **PASS** | Two induced failures (nonexistent model on a live endpoint; dead endpoint port) plus one natural timeout, then recovery. | Accepted-prefix digest `2ca6ab528178e44e` unchanged throughout; AI-action count stayed at 6 across both failures; recovery via `continue` produced turn 7 with the prefix still unchanged. | The player's own typed action **is** committed before the model call, so the row count grows. §H. | +| **A06** Trusted-LAN Ollama inference | **PASS** | Run 2 (§F). Ollama 0.33.0 on a second physical machine over HTTPS; storyteller's default route deleted, LAN-only route added, resolver pointed at nothing, host supplied as a static hosts entry. | Model discovery returned both models; nine turns (4–14 s); memories embedded on the remote host; restart and resume; capture shows 893 packets to the approved host, 730 loopback, **0** elsewhere, **0** DNS queries. | Required the §G TLS fix first — the first attempt failed with `CERTIFICATE_VERIFY_FAILED`. | +| **H01** No unexpected outbound connections | **PASS for the application** | `tcpdump -i any` inside each run's network namespace, for the whole run. | Run 1: 6 074 packets, 6 062 loopback, **0 non-loopback unicast**. Run 2: 1 633 packets, 730 loopback, 893 to the approved host, **0** other unicast, **0** DNS. | Ollama itself queried `ollama.com` in Run 1 — not the storyteller, and it failed. §K. | +| **H02** No telemetry | **PASS** | The same captures, plus reading `analytics.py`. | No outbound destination in either capture. `analytics.py` writes two local SQLite tables, opens no socket, records no IP or user agent, and HMACs the user id. | The dashboard still exists in the UI. M2 removes it. | +| **H03** No cloud provider required | **PASS as written** | Runs 1 and 2, with nothing cloud reachable. | Every operation succeeded with no cloud endpoint and no key. | The test's *preferred* final state — "controls are absent, not merely unused" — is **not** met. That is M2's scope by design, not an M1 gap. | +| **H11** No first-use runtime asset download | **PASS** | Run 1 on a **fresh database and fresh container** with no route out: first turn generated, then the full asset graph fetched. | First turn succeeded where upstream raised `ConnectionError`. `index.html` references only same-origin URLs; all 8 woff2 files served locally as `font/woff2`; CSP names no remote origin. Independently: with sockets blocked, upstream's code path raises `AssertionError: socket opened` and the vendored path returns a token count. | Browser devtools were not inspected; the claim rests on the server-side asset graph and the CSP. | + +**Not tested, and not claimed:** any acceptance test outside M1's scope (B, C, +D, E, F, G, I, J, K series). No result above is inferred from source +inspection alone. + +--- + +# F. Offline and Network Evidence + +### Run 1 — same-host Ollama, air-gapped + +| | | +| --- | --- | +| Topology | Ollama in one container; the production image in a second container **sharing Ollama's network namespace**, so Ollama is genuinely on the storyteller's loopback. Network is Docker `--internal`: no NAT, no external DNS. | +| Storyteller listener | `127.0.0.1:8000` | +| Ollama endpoint | `http://127.0.0.1:11434/v1` — same-host loopback | +| Outbound Internet actually blocked? | **Yes**, proven before testing: `1.1.1.1:443` → `OSError: Network is unreachable`; `openaipublic.blob.core.windows.net`, `fonts.googleapis.com`, `fonts.gstatic.com`, `openrouter.ai`, `github.com` → `gaierror` | +| Expected destinations observed | `127.0.0.1:8000` (API), `127.0.0.1:11434` (Ollama), `127.0.0.1:11499` (the deliberately dead port in A05), two ephemeral loopback ports (Ollama's model runner) | +| Unexpected attempts | **None from the application.** 6 062 of 6 074 packets loopback; **0 non-loopback unicast**; the remaining 12 are received mDNS/ICMPv6 multicast from the bridge. | +| DNS | Four queries, all `ollama.com`, all `ServFail` — issued by **the Ollama server**, which shares the namespace. Not the storyteller. §K. | +| First-turn asset download | **None.** Fresh database, fresh container; the first turn narrated instead of raising `ConnectionError`. No font, tokenizer or script request appears in the capture. | + +### Run 2 — trusted-LAN Ollama, Internet blocked + +| | | +| --- | --- | +| Topology | Ollama 0.33.0 on `inference.lan` (`192.168.0.50`), a **separate physical machine** on the trusted LAN, HTTPS, certificate from a local StartOS CA. Storyteller in a container on this host with `NET_ADMIN`: default route **deleted**, replaced by a route to `192.168.0.0/24` only; resolver pointed at nothing; the host supplied as a static hosts entry. | +| Storyteller listener | `127.0.0.1:8000` — nothing on the container's own LAN-facing address | +| Ollama endpoint | `https://inference.lan:8443/v1` — explicitly configured, non-loopback, TLS | +| Outbound Internet actually blocked? | **Yes.** By IP: `1.1.1.1`, `140.82.121.4`, `104.16.0.1` → `Network is unreachable`. By name: `github.com`, `openrouter.ai`, `fonts.gstatic.com`, `openaipublic.blob.core.windows.net` → `gaierror`. The LAN host resolved and TLS-verified: `peer CN = inference.lan`. | +| Expected destinations observed | `127.0.0.1:8000` (17 connections), `192.168.0.50:8443` (19 connections) | +| Unexpected attempts | **None.** 1 633 packets: 730 loopback, 893 to the approved host, **0 other unicast**. | +| DNS | **Zero queries of any kind** — the endpoint was configured, not resolved. | +| First-turn asset download | **None.** No name resolves at all, and the turn succeeded. | + +Confirmed by the application's own request log: every model request went to +`…/v1/chat/completions` and `…/v1/embeddings` on the configured host, and no +other URL — narrator, summariser and embedder alike. + +### Run 3 — native, non-Docker + +Production build from the venv, SPA served by FastAPI, Ollama on host loopback. +`LISTEN 127.0.0.1:8000` and `127.0.0.1:11434`; the LAN address refuses +connections. A campaign was created and played, and this is the instance the +browser check used. **This run had Internet available** — its purpose was the +native listener check and a real out-of-Docker run, not the offline proof. + +--- + +# G. tiktoken, Fonts, CSP, and Runtime Asset Fixes + +### The tokenizer download + +**Cause.** `backend/app/context/builder.py` called +`tiktoken.get_encoding("cl100k_base")`. That fetches the BPE table from +`openaipublic.blob.core.windows.net` on first use and caches it under the +system temp directory. `count_tokens` runs on **every** turn, for context +budgeting. On a developer machine that had been online once the cache was warm +and the download invisible; on an air-gapped install the first turn died with +`ConnectionError` instead of narrating. + +**Fix.** The table is vendored at +`backend/app/context/vendor/cl100k_base.tiktoken`, and +`backend/app/context/encoding.py` constructs the `Encoding` directly from it — +the same merge table, pattern string and special tokens `tiktoken` uses. +Nothing in the tokenizer path can reach the network: not a cache that happens +to be warm, not an environment variable a deployment could forget. + +Three things make this trustworthy rather than merely working: + +- The file's SHA-256 is `223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7`, + **identical to the digest `tiktoken_ext/openai_public.py` pins for that URL**, + and it is re-checked every time the encoding is built. A truncated checkout + or a substituted table fails loudly instead of silently changing every token + count the context budget derives from. +- A test asserts that digest still appears in `tiktoken`'s own source, so a + future upgrade pointing `cl100k_base` at a different table is caught. +- The encoding was compared token-for-token against `tiktoken.get_encoding` + across ASCII, accented text, CJK, emoji, CRLF and special-token literals. + +**Proof the guard is real**, with `TIKTOKEN_CACHE_DIR` pointed at an empty +directory and Python's socket functions replaced: + +```text +UPSTREAM PATH raises: AssertionError socket opened +VENDORED PATH: 2 tokens, no socket opened +``` + +### Remote fonts + +**Behaviour.** `frontend/index.html` carried two `preconnect` hints and a +stylesheet `` to `fonts.googleapis.com` for Cinzel, Crimson Pro and +Inter. Every page load fetched that stylesheet and then font files from +`fonts.gstatic.com` — an Internet dependency at runtime, and a third party +learning when the story is being read. + +**Fix.** Self-hosted. `frontend/tools/vendor_fonts.py` downloads the same faces +once at development time into `frontend/public/fonts/` and generates +`frontend/src/styles/fonts.css`. Variable fonts and the Latin + Latin-Ext +subsets: 8 files, 343 KiB, covering every weight the design uses. OFL text +ships beside them. Greek, Cyrillic and Vietnamese subsets are deliberately not +vendored; text in them falls back to the system stack. + +### CSP + +```diff +- style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; +- font-src https://fonts.gstatic.com; ++ style-src 'self' 'unsafe-inline'; ++ font-src 'self'; ++ object-src 'none'; base-uri 'none'; form-action 'self'; +``` + +Final policy, as served: + +```text +default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; +font-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; +base-uri 'none'; form-action 'self'; frame-ancestors 'none' +``` + +No remote origin remains. `'unsafe-inline'` stays on `style-src` because React +writes inline `style` attributes; it is deliberately absent from `script-src`. + +Separately, `woff2` was being served as `application/octet-stream` because +Python's mimetypes table has no entry for it on a slim Debian image. Browsers +accept it anyway — a `@font-face src` carries its own `format()` hint — but +`main.py` now registers the correct type. + +### Outbound TLS *(unplanned — see §J)* + +**Cause.** `httpx` verifies against the `certifi` bundle, which carries the +public web's CAs and nothing else. A trusted-LAN Ollama frequently has no +public certificate. Against a real StartOS-hosted Ollama the connection test +returned: + +```text +{"ok": false, "detail": "Connection failed: [SSL: CERTIFICATE_VERIFY_FAILED] + certificate verify failed: self-signed certificate in certificate chain"} +``` + +while `curl` and the browser on the same machine accepted the identical +endpoint, because the CA was installed in the **system** store. Measured: + +```text +certifi bundle (httpx default) FAIL SSLCertVerificationError +system trust store OK peer CN=inference.lan +``` + +**Fix.** `backend/app/tlstrust.py` builds one cached context that **unions** +the platform CA store with certifi's bundle; all four outbound clients use it. +A union rather than a swap on purpose: the platform store alone would be a +behaviour *change*, and an image with an empty or stale system store would +start failing on endpoints that previously worked. A union can only add trust +the user already granted at the OS level. + +Verification is untouched — `verify_mode=CERT_REQUIRED`, `check_hostname=True`, +and **no "insecure" escape hatch was added**. After the change: + +```text + OK inference.lan (local CA) CN=inference.lan + OK github.com (public CA) CN=github.com + OK pypi.org (public CA) CN=pypi.org + certifi-only context still rejects inference.lan (so the union is what changed) +``` + +### Fresh-data offline first-turn test + +**Passed.** Run 1 used a fresh Docker volume and a fresh container on a network +with no route out and no external DNS. The first turn of a newly created +campaign generated and persisted. No tokenizer, font or other runtime asset +request appears in the 6 074-packet capture. + +--- + +# H. Persistence and Failure Recovery + +### Clean restart + +| Run | Before | After | +| --- | --- | --- | +| 1 (same-host) | 12 actions, head 27 | 12 actions, head 27, transcript identical line for line | +| 2 (trusted-LAN) | 18 actions, head 18, digest `f24caf86a744ab36` | 18 actions, head 18, digest `f24caf86a744ab36` | + +Run 2 additionally confirmed the endpoint (`https://inference.lan:8443/v1`), +the embedding model and all memories survived, and produced a further turn in +6.4 s afterwards. + +### Failed model request + +Baseline: 12 actions, 6 of them AI, accepted digest `2ca6ab528178e44e`. + +```text +invalid local model -> events ['player','error'] + "Endpoint or model not found (HTTP 404) … model + 'no-such-model-v9' not found" + 13 actions, still 6 AI actions +endpoint down (:11499) -> events ['player','error'] + "Could not connect to http://127.0.0.1:11499/v1 — + is the AI server running?" + 14 actions, still 6 AI actions +accepted prefix through the pre-failure head: + 12 actions, digest 2ca6ab528178e44e — unchanged +rows added by the failures: + [28] story 'I strike a match.' + [29] story 'I strike a match again.' +recovery (continue) -> 40 SSE events, done; 15 actions, 7 AI actions + prefix digest still 2ca6ab528178e44e +``` + +**No partially accepted turn, in either failure.** The AI-action count never +moved; the accepted prefix is bit-identical before, during and after. + +**One behaviour the reviewer should know.** The player's own typed action is +committed *before* the model is called (`run_player_turn`), so a failed turn +leaves the player's text at the head with no reply, and the row count grows. +That satisfies A05 as written — prior story intact, failed turn not committed +as accepted, retry available — and it is deliberate: a model failure never eats +what the player typed. It is stated because "the story is unchanged" is not +literally true; "the accepted story is unchanged" is. Whether a dangling player +action is the right *user-facing* recovery state is an M3 question. + +--- + +# I. Test Suite / Regression Baseline + +| Check | Command | Result | +| --- | --- | --- | +| Backend, before M1 | `python -m pytest tests/ -q` on the pinned upstream commit | **632 passed, 0 failed**, 184.7 s | +| Backend, after M1 | `python -m pytest tests/ -q` | **648 passed, 0 failed, 0 skipped**, 212.1 s | +| Backend, **with no Internet** | same suite in a container on an `--internal` network | **648 passed, 0 failed**, 253.6 s | +| Frontend lint | `npm run lint` (oxlint) | **exit 0**, 6 warnings, 0 errors | +| Frontend build | `npm run build` (vite 8.1.3) | **succeeds**, 1.13 s | +| Image | `docker build .` | **succeeds** | +| Typecheck | — | none exists; the SPA is plain JSX with no TypeScript config. | + +The 632-test baseline was captured on the pinned upstream commit *before* any +change and matches the Phase 0B figure independently. + +### New M1 tests — 16 + +**`backend/tests/test_offline_assets.py` (10)** — vendored table present and +intact; the pinned digest still matches `tiktoken`'s own; token counting opens +no socket (sockets monkeypatched to raise); golden token counts; round-trip +over awkward characters; CSP names no remote origin; `index.html` fetches +nothing remote; stylesheets fetch nothing remote; every declared font file +exists; the built SPA is clean. + +**`backend/tests/test_tls_trust.py` (6)** — verification not weakened; context +cached; every certifi root survives the union; and an **AST walk** over the two +modules that make outbound requests asserting each `httpx.AsyncClient` passes +`verify=`, plus that no third module has started making requests. That last one +catches the failure no runtime test would: a *new* client added later, which +would work perfectly until someone pointed it at a LAN endpoint. + +### Remaining failures + +**None.** No failing test, no skipped test, no documented exception, and **no +M1 regression**. Nothing was inherited red — the 632-test baseline was green on +the first attempt on the pinned commit. + +Two `dist`-reading tests in `test_offline_assets.py` skip when +`frontend/dist/` has not been built. In the runs above it had been, so they +executed and are counted in the 648. + +### Internet requirement + +**No test requires Internet access** — confirmed by running the entire suite in +a container with no route out and no DNS. All 648 passed. Two pre-existing +warnings persist: a Starlette `httpx` deprecation notice, and a `SyntaxWarning` +for an invalid escape sequence in `tools/rewrite_memories.py`, both inherited +and both untouched by M1. + +--- + +# J. Deviations From the M1 Plan + +### Added scope + +1. **Outbound TLS trust (`backend/app/tlstrust.py`) — the significant one.** + Not in M1's scope list. `BUILD-MILESTONES.md` places "explicit Ollama + endpoint policy" in M2. But M1's own scope requires *verifying* trusted-LAN + generation, and its Definition of Done requires turns through such a host. + Against a realistic LAN Ollama — TLS with a locally-issued certificate — that + was impossible without this change. The choice was to change it or to report + A06 as blocked. It is small (one module, four call sites), does not weaken + verification, and adds no configuration surface. + +2. **`woff2` media type** (one line in `main.py`). Found while verifying the + self-hosted fonts. Cosmetic, but wrong is wrong. + +3. **`backend/requirements.lock`.** M1 says "reproducible dev/test + environment"; the inherited `requirements.txt` uses `>=` throughout, so a + fresh checkout resolved to whatever was newest that day. The lock is the + concrete deliverable for that line. + +4. **`base-uri`, `object-src`, `form-action` in the CSP.** M1 says "tighten the + CSP as needed". Removing the two Google hosts was the requirement; these + three cost nothing and were added while the policy was open. + +5. **`/phase0b/` in `.gitignore`** — housekeeping, so `git status` is readable. + +### Omitted scope + +**None.** Every item in M1's scope list was implemented and demonstrated. + +### Changed assumptions + +1. **"Trusted-LAN" was assumed to mean plain HTTP.** Every planning reference + writes `http://…:11434/v1`, and Ollama's own default is cleartext. The real + LAN host serves **HTTPS only** (plain HTTP 307-redirects), because it is a + StartOS server. This is what surfaced the TLS defect. §L. + +2. **A06 was initially reproduced without physical separation.** Before a + second machine was available, Run 2 used a separate container, namespace and + IP, and was going to be reported as PASS-with-qualification. A real second + machine then became available and the run was redone properly — which is + what exposed the TLS defect the container stand-in had hidden, since a + container endpoint was plain HTTP. + +### Workarounds + +1. **Blocking Internet without root.** No passwordless sudo here, so host + firewall rules were unavailable. Run 1 used a Docker `--internal` network. + Run 2 used `NET_ADMIN` inside the container to delete its default route and + add a LAN-only route — stronger than a firewall rule, since there is no + route to drop packets on. + +2. **Packet capture without root.** `tcpdump` in a container joined to the + target namespace with `NET_RAW`. + +3. **Browser verification by hand.** No browser automation in the session; the + maintainer confirmed the render directly. Devtools were not inspected. §K. + +### Unexpected inherited behaviour + +Four, all in §K: the model timeout, Ollama's own DNS lookup, the per-adventure +memory-bank defaults, and the pre-model commit of the player's action. + +--- + +# K. Technical Findings / Surprises + +### Affecting local-only security + +1. **Ollama phones home.** In Run 1 the capture shows four DNS queries for + `ollama.com`, issued by the **Ollama server**, not the storyteller. They + failed and nothing depended on them. But it sits inside the user's trust + boundary and outside this application's code, and a user reading a packet + capture will see it. The local-only claim covers what *this* application + sends; that distinction should be stated in the release material rather than + discovered. Suppressing it is an Ollama configuration question, deliberately + not investigated here. + +2. **Loopback in Docker depends on the publish flag, not the bind.** The + in-container listener is `0.0.0.0`, which is the only address a published + port can reach. `docker-compose.yml` publishes `127.0.0.1:8000:8000`, so the + shipped path is safe — but a hand-run `docker run -p 8000:8000` puts an + unauthenticated storyteller on the LAN. Comments now say so in both files. + Whether the app should *refuse* to serve on a non-loopback bind without an + explicit opt-in is an M2 policy question. + +### Affecting trusted-LAN inference + +3. **The TLS defect (§G) is the most important finding in M1.** Beyond the fix, + the lesson is methodological: it was undetectable by static review and + undetectable by every local run that used loopback HTTP, because + certificate verification never happens there. It surfaced within minutes of + pointing the application at a real host. **M2's endpoint-policy work should + be validated against a real LAN host, not a container stand-in.** + +4. **A CA in a container is not the CA on the host.** Running against a + private-CA endpoint from Docker requires the CA inside the image or + bind-mounted. Documented in `DEVELOPMENT.md`; it will matter for any future + packaged distribution. + +5. **The model timeout is 120 s and hardcoded** (`httpx.Timeout(120, + connect=10)`). On this GPU-less four-core host a *cold* model load, or two + models contending after the memory bank pulls in the embedder, exceeded it + three times. Once warm, a full turn took 6–9 s. Presented as a tuning + finding rather than a defect — but a first-run user on modest hardware will + likely meet it, and a configurable timeout is a small change. + +### Affecting dependency packaging + +6. **The vendored tokenizer table is 100 256 lines / 1.7 MB.** It dominates the + M1 diff. The alternative — `TIKTOKEN_CACHE_DIR` — was rejected as a + deployment-time promise that a packaging step could forget. The digest check + makes the vendored copy auditable. + +7. **`certifi` is now a direct dependency**, declared in `requirements.txt` + rather than arriving via httpx. + +8. **Node version.** The Dockerfile builds on Node 24 with a comment saying + npm 10 refuses the lockfile; `npm ci` in fact succeeded on Node 22.23.1 / + npm 10.9.8. No `.nvmrc` was added, because asserting a constraint that was + not tested would be worse than documenting what was. + +### Affecting browser/runtime assets + +9. **`docs/*.html` still links Google Fonts.** Upstream's GitHub Pages project + site — not served by the application, not part of any build, not covered by + the runtime rule. Deliberately untouched; the regression tests scope + themselves to `frontend/` so they do not give a false signal about it. + +10. **Only devtools were left unchecked.** The page's whole asset graph was + fetched and verified same-origin with no route out, the source and built + bundle contain no remote reference, and the CSP would block one. A devtools + capture during an offline session is the one more direct form of this + evidence and costs about a minute. + +### Affecting model-provider configuration + +11. **`Settings.model` defaults to `""`.** A new install cannot generate a turn + until a model is chosen, and nothing prompts for it (§D). + +12. **`netguard` is inert locally by design.** It rejects non-public addresses + only when `AIDND_MULTI_USER=1`, which local installs never set — which is + why a LAN endpoint is accepted at all. M2's endpoint policy replaces this, + and should keep that asymmetry deliberate rather than inherit it silently. + +### Affecting repository structure and testability + +13. **The fork merge preserves all 172 upstream commits**, so upstream fixes + can still be cherry-picked. Worth protecting: a future squash or filter + would throw it away. + +14. **The AST-based test in `test_tls_trust.py`** guards a class of regression + no runtime test can reach — a new HTTP client added later. The same shape + may be worth reusing in M2 when the provider surface is cut down. + +15. **The suite is fully offline-capable** (§I), so CI needs no network beyond + dependency install. + +### Affecting future removal of hosted/cloud/scripting code + +16. **M1 removed nothing**, so M2's removal surface is exactly what Phase 0B + described. Two M1 changes touch files M2 will edit heavily — + `providers/openai_compatible.py` and `routers/settings.py` — but both are + one-line `verify=` additions, so the TLS work should survive that + refactoring as long as the shared context follows any new client. + +--- + +# L. Planning Documents That May Need Revision + +Reported, not edited. No planning architecture document was changed by M1. + +### 1. `planning/DECISIONS/002-ollama-only-v1.md` — Consequences + +**Discrepancy.** The ADR treats a trusted-LAN Ollama as an addressing question +only. It does not anticipate that such a host may be reachable **only over +TLS**, with a certificate from a private CA. The real test host is exactly +that, and the application could not talk to it until M1 changed how outbound +TLS is verified. + +**Recommended correction.** Add a consequence: the storyteller must verify +against the operating system's CA store as well as the bundled one, so a user +who has installed their own CA is honoured; and no option to skip verification +should be offered. + +### 2. `planning/DECISIONS/004-local-only-production.md` — Consequences + +**Discrepancy.** "Production packaging must contain all runtime assets required +for ordinary story use" is right but understates it. Both M1 findings were +*first-use* downloads invisible on any machine that had been online once, and +neither was findable by static analysis. + +**Recommended correction.** Add that offline claims must be validated on a +network with no route out and a fresh cache, and that vendored runtime assets +should carry a verifiable digest. + +### 3. `planning/V1-ACCEPTANCE-TESTS.md` — A06 + +**Discrepancy.** A06's preconditions do not say whether the LAN endpoint is +HTTP or HTTPS, and every example elsewhere shows `http://`. The HTTPS +private-CA case is the one that broke, and A06 as written could be passed +against a plain-HTTP host without ever exercising it. + +**Recommended correction.** Add a step covering an HTTPS endpoint with a +locally-issued certificate, and a pass condition that verification is performed +rather than bypassed. + +### 4. `planning/V1-ACCEPTANCE-TESTS.md` — A05 + +**Discrepancy.** "Failed turn is not partially committed as accepted" is +satisfied, but the observable state is a dangling player action at the head +with no reply (§H). A reader could reasonably expect the row count not to move. + +**Recommended correction.** State that the player's own input is retained by +design, and that the invariant is about *accepted* history. + +### 5. `planning/BUILD-MILESTONES.md` — M1 Scope + +**Discrepancy.** M1's scope lists four hardening items but not outbound TLS +trust, which turned out to be on its critical path. + +**Recommended correction.** Note retrospectively that M1 also covered it, so +M2's "explicit Ollama endpoint policy" is not planned as if it were still open. + +### 6. `planning/V1-ACCEPTANCE-TESTS.md` §3 — Standard Test Environment + +**Discrepancy.** The environment list assumes one local Ollama and does not +mention hardware class. Model cold-load time on a GPU-less host exceeded the +application's fixed 120 s timeout three times during M1. + +**Recommended correction.** Record hardware class alongside model name, and +note that a cold load may exceed the client timeout on modest hardware. + +--- + +# M. Risks / Technical Debt Carried Into M2 + +### Blockers before M2 + +**None.** + +### Acceptable technical debt + +| Item | Risk | Suggested milestone | +| --- | --- | --- | +| **120 s hardcoded model timeout.** Cold loads on modest hardware exceed it; the user sees "The AI endpoint timed out." | Medium — a first-run user on a slow box may conclude the app is broken. | M2, with the endpoint policy | +| **Ollama's own `ollama.com` lookup.** Outside this codebase, inside the user's trust boundary. | Low technically, medium for the local-only claim. | Decide before release; document either way | +| **Docker loopback depends on the publish flag.** A hand-run `-p 8000:8000` exposes an unauthenticated API to the LAN. | Medium. | M2 | +| **`Settings.model` defaults to `""`.** No first turn until a model is chosen; nothing prompts. | Low. | M8 | +| **Memory bank off per adventure by default** even with an embedding model configured. Cost me a wasted verification cycle. | Low, but it makes M6's features look absent. | M6 | +| **Vendored tokenizer table, 1.7 MB.** Needs re-vendoring if the encoding ever changes. | Low — digest-checked, and a test catches a `tiktoken` change. | — | +| **No frontend typecheck.** Plain JSX, lint only. | Low. | M8 | +| **`docs/*.html` links Google Fonts.** Not served by the app. | Very low. | M2 or M8 | +| **The `dist` tests skip when the SPA is unbuilt.** A green suite alone is not full evidence. | Low — documented in `DEVELOPMENT.md`. | — | + +### Inherited AI-DnD behaviour intentionally left in place + +All of this was outside M1's scope and is **explicitly non-scope** in the M1 +brief. It is listed so nobody reports it as an M1 gap: + +- multi-user/account/guest/auth flows, demo-key behaviour, hosted rate limits; +- the self-hosted analytics tables and Visitors dashboard (local-only, no + outbound request); +- Render and Neon deployment paths; Postgres/`psycopg` support; +- the OpenRouter default-endpoint constant and attribution-host check; +- arbitrary remote model-provider configuration in the Settings UI; +- QuickJS campaign scripting and the AI-Dungeon-compatible script surface; +- **destructive Undo with no Redo** — verified in code, and the single largest + inherited correctness gap. M3; +- RPG world-state machinery and relative-delta proposals. M5; +- Story Cards as the only imported-knowledge mechanism. M7; +- AI-DnD's UI organisation and vocabulary. M8. + +--- + +# N. Reproduction / Verification Commands + +### Environment + +```bash +git clone && cd interactive-story +git checkout m1-production-baseline +python3 -m venv backend/.venv +backend/.venv/bin/pip install -r backend/requirements.lock +(cd frontend && npm ci && npm run build) +``` + +### Provenance + +```bash +git remote add upstream https://github.com/parththakkar106/AI-DnD.git +git fetch --no-tags upstream +git cat-file -t d72f7c1bda0f34fccd84afb7a25c34eb01c901de # -> commit +git merge-base --is-ancestor d72f7c1bda0f34fccd84afb7a25c34eb01c901de HEAD +git diff --stat d72f7c1bda0f34fccd84afb7a25c34eb01c901de HEAD -- LICENSE # -> empty +``` + +### Tests + +```bash +(cd backend && .venv/bin/python -m pytest tests/ -q) # 648 passed +(cd frontend && npm run lint && npm run build) +docker build -t storyteller . +``` + +Prove the suite needs no network: + +```bash +docker network create --internal offline +docker build -t storyteller . && \ + printf 'FROM storyteller\nRUN pip install --no-cache-dir pytest\n' | docker build -q -t storyteller-test - +docker run --rm --network offline -v "$PWD":/src:ro -w /src/backend \ + -e AIDND_DB_PATH=/tmp/test.db storyteller-test python -m pytest tests/ -q +``` + +### Run 1 — offline, same-host Ollama + +```bash +docker network create --internal offline +docker run -d --name ollama --network offline -v ollama-models:/root/.ollama ollama/ollama +docker exec ollama ollama pull qwen2.5:3b-instruct + +# The app shares Ollama's network namespace, so Ollama is on its loopback and +# neither has a route out. +docker run -d --name app --network container:ollama -v story-data:/data \ + storyteller uvicorn app.main:app --host 127.0.0.1 --port 8000 + +# Isolation, before testing anything: +docker exec app python -c "import socket; socket.create_connection(('1.1.1.1',443),timeout=4)" +# -> OSError: Network is unreachable + +# Listener: +docker exec app sh -c "grep -c . /proc/net/tcp" # or the /proc parser in the baseline report +``` + +Configure and play: + +```bash +docker exec app python - <<'PY' +import json, urllib.request +B = "http://127.0.0.1:8000/api" +def call(m, p, b=None): + d = json.dumps(b).encode() if b is not None else None + r = urllib.request.Request(B+p, data=d, method=m, headers={"Content-Type":"application/json"}) + with urllib.request.urlopen(r, timeout=900) as f: t = f.read().decode() + return json.loads(t) if t.strip() else None +call("PUT", "/settings", {"endpoint_url":"http://127.0.0.1:11434/v1", + "model":"qwen2.5:3b-instruct","api_mode":"chat","api_key":"", + "max_output_tokens":120,"context_token_budget":2048}) +print(call("POST", "/settings/test")) +adv = call("POST", "/adventures", {"title":"Continuity Test","persona_name":"Vale"})["id"] +d = json.dumps({"type":"story","text":"I am Vale, a lighthouse keeper."}).encode() +r = urllib.request.Request(f"{B}/adventures/{adv}/actions", data=d, method="POST", + headers={"Content-Type":"application/json","Accept":"text/event-stream"}) +with urllib.request.urlopen(r, timeout=900) as f: + for line in f: + line = line.decode().strip() + if line.startswith("data: ") and '"done"' in line: + print(json.loads(line[6:])["action"]["text"][:120]) +PY +``` + +Restart and resume: `docker restart app`, then re-read +`GET /api/adventures/{id}/actions?limit=500` and compare. + +### Run 2 — trusted-LAN Ollama, Internet blocked + +On the **inference machine**, if it serves plain HTTP: + +```bash +OLLAMA_HOST=0.0.0.0:11434 ollama serve +ollama pull qwen2.5:3b-instruct && ollama pull nomic-embed-text +``` + +If it serves HTTPS with a private CA, install that CA where the storyteller +runs — inside the image for a container: + +```dockerfile +FROM storyteller +COPY local-ca.crt /usr/local/share/ca-certificates/ +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates iproute2 \ + && update-ca-certificates && rm -rf /var/lib/apt/lists/* +``` + +On the **storyteller machine** — LAN reachable, Internet not: + +```bash +docker run -d --name app-lan --cap-add=NET_ADMIN \ + --add-host inference.lan:192.168.0.50 --dns 127.0.0.1 \ + -v story-data-lan:/data storyteller-lan \ + uvicorn app.main:app --host 127.0.0.1 --port 8000 + +docker exec app-lan ip route del default +docker exec app-lan ip route add 192.168.0.0/24 via 172.17.0.1 dev eth0 +``` + +Then set `endpoint_url` to `https://inference.lan:8443/v1` (or +`http://192.168.0.50:11434/v1`) and play as above. `POST /api/settings/test` +should list the remote host's models. + +### Packet capture (no host root needed) + +```bash +printf 'FROM alpine\nRUN apk add --no-cache tcpdump\n' | docker build -q -t tcpdump-img - +docker run -d --name cap --network container:app --cap-add=NET_RAW \ + -v "$PWD/cap":/cap tcpdump-img tcpdump -i any -n -w /cap/run.pcap +# … run the campaign … +docker stop cap +docker run --rm -v "$PWD/cap":/cap tcpdump-img sh -c ' + tcpdump -r /cap/run.pcap -nn "not net 127.0.0.0/8 and not host 192.168.0.50 and not multicast" | wc -l + tcpdump -r /cap/run.pcap -nn -vv "udp port 53" | grep -oE "q: [A-Z]+\? [^ ]+" | sort | uniq -c' +``` + +### The one uncommitted file + +```bash +git add planning/reports/M1-BASELINE-REPORT.md planning/reports/M1-IMPLEMENTATION-REPORT.md +git commit -S -m "Add the M1 implementation review report" +``` + +--- + +# O. Recommendation + +## READY FOR M2 WITH NOTED NON-BLOCKING ISSUES + +M1 delivered its whole scope and its Definition of Done is met on runtime +evidence rather than inspection: the application starts and plays with no route +to the Internet, through same-host Ollama and through Ollama on a second +physical machine; campaigns persist across restart; a failed model call leaves +accepted history bit-identical; no runtime asset is fetched; the listener is +loopback in every run; and the suite is green at 648 tests, including with no +network at all. Nothing was removed from the inherited codebase, so M2 begins +against exactly the surface Phase 0B described. + +The issues in §M are non-blocking and mostly land naturally inside milestones +that already exist. Two deserve a decision rather than a queue entry: the +hardcoded 120 s model timeout, which a first-run user on modest hardware will +probably meet before anything else, and Ollama's own `ollama.com` lookup, which +is outside this codebase but inside the claim the product makes about itself. + +The finding worth carrying into M2 planning is not a defect but a method. The +TLS gap was invisible to static review and to every local run that used +loopback HTTP, and it appeared within minutes of pointing the application at a +real LAN host. M2 owns the endpoint policy; it should be validated the same +way, against a real second machine rather than a container stand-in.