The planning package had grown to where a new agent could not tell what was authoritative. Phase 0 execution prompts sat beside the specification; four completed milestone reports sat beside the current one; and upstream AI-DnD's own `plan/` build log and `docs/` project site still described a hosted, scripted, multi-user product with accounts — every screenshot in it showed a Scripts tab and a Sign up button, none of which has existed since M2. `planning/archive/` now holds the history and says so in its own README: `phase0/` for the research that chose AI-DnD, `milestone-reports/` for M1 and M2, `decisions/` for ADR 008, the Phase-0-before-build gate Phase 0 satisfied. `planning/reports/` holds only the current milestone's report, because that is the one M4 planning has to read; it moves to the archive when M4's replaces it. Deleted rather than archived: the Phase 0B execution prompts and the handoff/status/summary documents, the Phase 0A discovery and triage reports, upstream's `plan/` and `docs/` trees, and `frontend/README.md`, which was Vite's template boilerplate. All of it is in Git history, and the two recommendation reports carry every conclusion the deleted research reached. Archived documents are kept verbatim. Paths written inside them point at where those files were when the document was written, which is the point: an evidence record that has been quietly edited is no longer evidence. Active documentation is corrected where it pointed at the removed trees or described removed capability as present. `DEVELOPMENT.md`'s "things M1 did not touch" list had gone stale at M2 and claimed QuickJS scripting was still tested; its test count was 604 against an actual 638. `README.md` loses the upstream CI badge, which reported upstream's pipeline rather than this fork's, and a reference to `backend/app/worldstate/engine.py`, a file that does not exist. `planning/README.md` is rewritten as the documentation index. New: `planning/PROJECT-SOURCES.md` and `planning/project-sources.txt`, the manifest of what belongs in the ChatGPT project's Sources. Source comments referring to the deleted trees are reworded; no behaviour changes. 638 backend tests pass, frontend lints and builds, and a reference scan over all 48 tracked Markdown files reports no unresolved path in active documentation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCbwH7yLGKsj1rhXXzKSCu
53 KiB
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:
$ 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:
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.
.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
- Start the application locally —
./start.shfor development, or a built SPA served by FastAPI on127.0.0.1:8000for production. Both bind loopback. - Configure a model — Settings → endpoint URL, model, API mode, output and context budgets. Test connection lists the endpoint's models.
- Create a campaign and give it a persona.
- Generate narration — Do / Say / Story / Continue, streamed over SSE.
- Persist and resume — campaigns survive a clean restart with transcript and head position intact; the adventures list reopens them.
- Use same-host Ollama —
http://127.0.0.1:11434/v1, the default endpoint, with no Internet at any point. - Use trusted-LAN Ollama — an explicitly configured endpoint on another
machine,
http://…:11434/v1orhttps://…/v1with a private CA, while the storyteller UI/API stays on loopback. - 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 digesttiktoken_ext/openai_public.pypins 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 pointingcl100k_baseat a different table is caught. - The encoding was compared token-for-token against
tiktoken.get_encodingacross 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:
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
- 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:
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:
{"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:
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:
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.
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
-
Outbound TLS trust (
backend/app/tlstrust.py) — the significant one. Not in M1's scope list.BUILD-MILESTONES.mdplaces "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. -
woff2media type (one line inmain.py). Found while verifying the self-hosted fonts. Cosmetic, but wrong is wrong. -
backend/requirements.lock. M1 says "reproducible dev/test environment"; the inheritedrequirements.txtuses>=throughout, so a fresh checkout resolved to whatever was newest that day. The lock is the concrete deliverable for that line. -
base-uri,object-src,form-actionin 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. -
/phase0b/in.gitignore— housekeeping, sogit statusis readable.
Omitted scope
None. Every item in M1's scope list was implemented and demonstrated.
Changed assumptions
-
"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. -
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
-
Blocking Internet without root. No passwordless sudo here, so host firewall rules were unavailable. Run 1 used a Docker
--internalnetwork. Run 2 usedNET_ADMINinside 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. -
Packet capture without root.
tcpdumpin a container joined to the target namespace withNET_RAW. -
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
-
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. -
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.ymlpublishes127.0.0.1:8000:8000, so the shipped path is safe — but a hand-rundocker run -p 8000:8000puts 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
-
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.
-
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. -
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
-
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. -
certifiis now a direct dependency, declared inrequirements.txtrather than arriving via httpx. -
Node version. The Dockerfile builds on Node 24 with a comment saying npm 10 refuses the lockfile;
npm ciin fact succeeded on Node 22.23.1 / npm 10.9.8. No.nvmrcwas added, because asserting a constraint that was not tested would be worse than documenting what was.
Affecting browser/runtime assets
-
docs/*.htmlstill 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 tofrontend/so they do not give a false signal about it. -
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
-
Settings.modeldefaults to"". A new install cannot generate a turn until a model is chosen, and nothing prompts for it (§D). -
netguardis inert locally by design. It rejects non-public addresses only whenAIDND_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
-
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.
-
The AST-based test in
test_tls_trust.pyguards 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. -
The suite is fully offline-capable (§I), so CI needs no network beyond dependency install.
Affecting future removal of hosted/cloud/scripting code
- 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.pyandrouters/settings.py— but both are one-lineverify=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/
psycopgsupport; - 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
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
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
(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:
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
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:
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:
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:
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:
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)
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
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.