Add the M1 implementation review report
This commit is contained in:
@@ -42,7 +42,7 @@ vendored font files and their licences. No upstream file was deleted.
|
|||||||
|
|
||||||
| Change | Files |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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.
|
||||||
Reference in New Issue
Block a user