Add the M1 implementation review report

This commit is contained in:
JesseMarkowitz
2026-09-02 05:27:47 -04:00
parent c1a73b3d77
commit 645f07f06d
2 changed files with 996 additions and 1 deletions
+1 -1
View File
@@ -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` |
@@ -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, <https://github.com/parththakkar106/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 `<link>` 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 `<link>` 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 <this repo> && 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.