Files
interactive-story/planning/TECHNICAL-DESIGN.md
T
JesseMarkowitzandClaude Opus 5 62a997f364 M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-04 06:34:56 -04:00

862 lines
35 KiB
Markdown

# Adventure Storyteller — Technical Design
**Status:** v1.0 — architecture selected after Phase 0B
**Production base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
## 1. Design Objective
Build a local-first, browser-based interactive storytelling application in which:
- Ollama provides inference on user-controlled local infrastructure, either same-host or on an explicitly approved trusted-LAN machine,
- the application owns authoritative story state,
- complete story history is retained,
- user-facing Undo/Redo/Retry/Save Point behavior is simple,
- internal history is non-destructive and lineage-aware,
- long-running context is reconstructed from state, summaries, retrieval, recent turns, and local knowledge,
- imported knowledge remains local and authority-classified,
- prompt/context provenance is inspectable,
- future image/video/audio/TTS/STT providers can be added without coupling them to the story engine.
The application is an interactive-story system, not a D&D rules engine.
## 2. Production Base Decision
AI-DnD is the production fork/base.
Retain its high-value foundation:
- React/Vite browser application,
- FastAPI backend,
- SQLite persistence/migrations,
- story-tree and lineage machinery,
- alternate takes,
- branch switching,
- per-node state snapshot pattern,
- local Ollama/OpenAI-compatible integration where appropriate,
- branch-scoped memory concepts,
- prompt/context snapshots and Insights concepts,
- SSE streaming,
- export/import framework,
- automated test foundation.
Do not inherit candidate behavior merely because it exists upstream. The product specification and detailed behavior documents remain authoritative.
## 3. Reference Projects and Their Role
### ai-adventure
Primary implementation reference for:
- explicit typed state events,
- model-proposes/application-validates discipline,
- atomic commit behavior,
- head movement instead of destructive Undo,
- named checkpoints,
- replay/state reconstruction,
- narrow local-only endpoint handling,
- deterministic lexical lore concepts.
It is **not** the written specification and does not override this design.
### Open Dungeon
Reference for:
- focused story-reading UX,
- inline generated media presentation,
- character visual continuity,
- local media-service boundaries.
It is not a production-fork candidate.
### Other references
- Chronicler: memory authority/trust tiers.
- Interactive Fiction Framework: canon/application-owned-state concepts.
- Gamentic: provider-neutral asynchronous media boundary.
## 4. Selected High-Level Architecture
```text
Local Browser
|
v
+-------------------+
| React/Vite UI |
+---------+---------+
|
v
+-------------------+
| FastAPI Story |
| Director/API |
+---+-----------+---+
| |
| +-----------------------+
v v
+----------------------+ +----------------------+
| SQLite Authoritative | | Context / Retrieval |
| Story Store | | local only |
+----------+-----------+ +----------+-----------+
| |
| +----------+-----------+
| | FTS + local semantic |
| | retrieval |
| +----------+-----------+
| |
+--------------------+---------------+
|
v
+----------------------+
| Ollama |
| localhost by default |
| or approved LAN host |
+----------------------+
```
Future optional extension:
```text
Accepted Story / Scene State
|
v
+------------------+
| Media Coordinator|
+--+---+---+---+---+
| | | | |
v v v v v
Image Video Audio TTS STT
local providers only by default
```
## 5. Local-Only Runtime Boundary
`Local-only` means user-controlled local infrastructure with no required Internet/cloud dependency; it does not require every process to share one host.
The architecture has **two boundaries, and they are not the same boundary**. The
storyteller is loopback-only, always. Inference may be same-host or on a
specifically configured trusted-LAN machine. Wording that describes Ollama as
simply "loopback/local" collapses the two and understates the intended
deployment:
```text
Browser ──loopback──> storyteller (FastAPI + SPA), bound to 127.0.0.1
│
├── same-host Ollama on 127.0.0.1:11434 (default)
│
└── OR an explicitly configured trusted-LAN Ollama
on another user-controlled machine
http://<host>:11434/v1 or https://<host>/v1
```
Read that as three separate rules:
1. **The storyteller's own listener is loopback, in every run path.** Dev
server, production server, and container alike. Nothing about the inference
choice changes it. Where a container must listen on `0.0.0.0` because a
published port cannot reach anything else, the port is published to the
host's loopback only.
2. **The inference endpoint is an outbound connection, chosen by the user.**
Same-host loopback is the default. A trusted-LAN host is a first-class,
supported v1 configuration — not a workaround and not a development-only
convenience.
3. **The two are independent.** Reaching a LAN Ollama never requires, and must
never cause, LAN exposure of the storyteller UI/API. There is no supported
v1 configuration in which the storyteller itself is reachable from the LAN.
### Transport to a trusted-LAN endpoint
A LAN inference host is often reached over **HTTPS with a certificate issued by
a private or local CA**, and may offer no cleartext port at all. This is
ordinary for a self-hosted server, so v1 must handle it rather than assume the
same plain HTTP that same-host loopback uses:
- outbound HTTPS verifies against the **operating system's trusted CA store** in
addition to any bundled certificate list, so a CA the user installed on their
own machine is honoured here as it is by `curl` and their browser;
- certificate **and hostname** verification stay fully enabled;
- there is **no "ignore TLS errors" option** anywhere — not in the UI, not in
configuration, not as an environment variable;
- the endpoint field therefore accepts `https://` on any port.
Prompts, story text, retrieved knowledge and embedding inputs all travel to
whichever inference host is configured, which is why it must be one the user
controls on a network they trust — and why the endpoint is always explicitly
configured, never discovered.
See ADR 002 (*Transport for a Trusted-LAN Endpoint*) and, for the demonstrated
deployment, `planning/archive/milestone-reports/M1-IMPLEMENTATION-REPORT.md` §F.
Allowed future local paths may also include explicitly configured local media services.
Production defaults must not require:
- cloud model providers,
- hosted authentication,
- analytics/telemetry,
- remote database services,
- runtime CDNs/fonts/assets,
- automatic web retrieval,
- external embeddings/vector stores,
- general plugins/MCP/shell execution.
### 5.1 Known AI-DnD hardening work
Phase 0B identified concrete inherited violations, and M1 added a fifth. **All
five are now resolved** — items 1, 2 and 5 in M1, items 3 and 4 in M2.
1. ~~`tiktoken` attempts to download the `cl100k_base` encoding on first use.~~
**Done in M1.** The encoding table is vendored in the tree and loaded
directly, with its SHA-256 verified against the digest `tiktoken` pins, so no
code path in the tokenizer can reach the network.
2. ~~the SPA requests Google Fonts at runtime.~~
**Done in M1.** All three families are self-hosted, and the CSP names no
remote origin at all.
3. ~~hosted/multi-user/auth/demo/analytics/Postgres/cloud-provider/QuickJS paths are unnecessary.~~
- remove them rather than merely hide them where practical.
- **Done in M2, in full.** Removed rather than hidden: 52 API routes fell to
36, and `/api/auth`, `/api/analytics` and `/api/scripts` are gone entirely
rather than gated. See §5.2.
4. ~~endpoint validation must reflect this product's threat model.~~
- same-host loopback Ollama is the default; an explicitly configured trusted-LAN Ollama endpoint is supported; arbitrary public/Internet model endpoints must be rejected or kept outside normal v1 configuration.
- inference endpoint configuration must not change the storyteller's own loopback bind behavior.
- **Done in M2.** `backend/app/endpoints.py` applies an address-based
allowlist on save and again before every outbound request. Endpoint
configuration has no influence on the storyteller's own bind address.
ADR 011; `SECURITY-THREAT-MODEL.md` §10A.
5. ~~outbound TLS verified only against a bundled public-CA list, so a LAN host
with a privately issued certificate was refused.~~
**Found and fixed in M1.** Not visible to Phase 0B: every run up to that
point used plain HTTP over loopback, where certificate verification never
happens. See *Transport to a trusted-LAN endpoint* above.
### 5.2 Production architecture as established by M1 and M2
The architecture below is no longer a selection; it is what the code does. It is
recorded here so later milestones inherit facts rather than intentions.
| | |
| --- | --- |
| Production base | AI-DnD, forked at `d72f7c1` (§2, ADR 009) |
| Persistence | SQLite. Postgres, Neon and the Render deployment path are removed |
| Inference | Ollama only. No cloud provider code, no API key, no key UI |
| Storyteller bind | loopback by default, in every run path including the published Docker port |
| Ollama endpoint | same-host loopback by default; an explicitly configured trusted-LAN endpoint is equally supported |
| Public endpoints | refused by address, on save and before every request |
| Trusted-LAN HTTPS | supported, with full certificate and hostname verification against the machine's CA store; no bypass exists |
| Runtime assets | self-contained. Tokenizer table and fonts are vendored; the CSP names no remote origin |
Removed in M2 rather than hidden: hosted accounts and auth, guest/demo
behaviour, hosted analytics, cloud inference providers, API-key storage and its
UI, Postgres/Neon/Render support, and QuickJS campaign scripting.
**A trusted-LAN Ollama endpoint is accepted production behaviour**, not a
development convenience. Any statement that the only valid endpoint is literally
`127.0.0.1` is stale and should be read against §5 and §10A of the threat model.
## 6. Browser UI Boundary
The browser remains a presentation/control layer, not the owner of story authority.
Primary areas:
- campaign library/setup,
- active story transcript,
- input composer,
- Undo / Redo / Retry,
- alternate-take selector,
- Save Points,
- current story state inspector,
- imported-knowledge manager,
- prompt/context inspector,
- local-model status/settings,
- export/import,
- future media gallery/actions.
Normal storytelling should not expose branch IDs, database rows, embeddings, or event logs unless the user opens advanced diagnostics.
## 7. Story Director Boundary
The FastAPI service owns the turn lifecycle:
1. resolve campaign, active branch, and active head,
2. load authoritative state at that head,
3. assemble bounded lineage-safe context,
4. retain prompt/retrieval provenance,
5. invoke local Ollama narrator,
6. stream provisional narration,
7. obtain/parse a structured state proposal,
8. validate the proposal,
9. atomically accept the turn plus validated state consequences,
10. update derived memory/summary/index data without allowing derived failures to corrupt the accepted turn,
11. expose the new current head to the browser.
A failed generation must not partially advance authoritative story state.
## 8. Non-Destructive History Model
### 8.1 Core rule
Undo moves the active head. It does not delete accepted history.
Phase 0B demonstrated that AI-DnD already contains the architectural chokepoints needed for this approach:
- stored head depth/position,
- lineage reads through a common path abstraction,
- branch-at-depth behavior.
The disposable spike is evidence, not production code to merge blindly.
### 8.2 Active head versus retained tip
The design distinguishes:
- **retained tip:** newest retained turn on a continuation,
- **active head:** the story position from which the user is currently reading/continuing.
After Undo, the active head may sit behind a retained tip.
Redo moves the head forward along the previous active continuation while no divergent write has occurred.
### 8.3 Divergence after moving backward
If the user writes/retries/edits from a head behind the retained tip:
- the new continuation forks on first write,
- the previous future remains retained,
- ordinary Redo into that old future is invalidated,
- the displaced future is marked abandoned/disposable,
- lineage-sensitive state, summary, and memory selection follows only the new active path.
### 8.4 Retry
Retry preserves alternate narrator takes for the same user input.
Production implementation must ensure retry/add-take while behind the current tip uses the same safe fork/head semantics as other writes.
### 8.5 Checkpoints / Save Points
A named checkpoint is a durable pointer to a recoverable story position.
Conceptually:
```text
campaign_id
branch_id
turn_id or equivalent head coordinate
name
notes
created_at
```
Restoring a checkpoint moves the active head to that position. The existing later future remains retained. A new branch is created only if/when the user creates a different continuation.
### 8.6 Abandoned history
No automatic cleanup policy is required in v1.
Abandoned history must:
- remain retained,
- be marked disposable/inactive through implementation-appropriate metadata,
- stop influencing current state/context/memory/summary,
- remain available for future recovery/cleanup features.
### 8.7 As implemented in M3
M3 built this model. The following is fact rather than direction, and ADR 012
records it as the architectural decision. Sections 8.1-8.6 stand; this says how
they were realised.
**The head is stored, not derived.** A campaign carries a branch and a depth,
and that pair is the active head. No read may recompute it from the newest row —
that was the pre-M3 behavior, and it is what made Redo impossible and made an
export reopen an undone campaign at its tip.
**Lineage reads are capped at the head, in one place.** The path abstraction that
already resolved a branch's ancestry now also limits every entry to the head, so
the transcript, the assembled narrator context, take/parent resolution and memory
retrieval narrow together. There is exactly one way to read past the head — a
named, uncapped view of the same lineage — and only two callers may use it: Redo,
and the check that decides whether a write must fork. Any new feature that reads
story rows directly, rather than through the capped lineage, will see retained
history the story is not telling.
**Head movement is one mechanism.** Undo, Redo, and anything later that restores
a position resolve a target depth and then call a single move operation, which
sets the coordinate and restores the state recorded at it. Undo and Redo differ
only in which way they resolve the target. Both step over a whole turn — a
player's action and the reply to it — so the head never rests between the two
halves of one turn.
**State comes from the node, not from a replay.** Each node records the state it
left behind, so moving the head is a row lookup plus a restore: the same cost at
any distance, in either direction, and identical whether the position is reached
from in front of it or from behind. This is the property §10.4's hybrid storage
must preserve.
**Divergence is a property of the lineage, not a flag.** The first write below a
moved-back head forks; Undo alone never does. After the fork, the displaced
future is no longer on the lineage being read, so ordinary Redo finds nothing
ahead and reports that it has nowhere to go. Nothing has to be invalidated,
cleared, or kept in step.
**A branch the story leaves records the depth and time it was left**, as metadata
nothing reads to decide behavior (§8.6's "implementation-appropriate metadata").
It makes a divergence observable and gives later cleanup and recovery features
something to select on; because no decision depends on it, a stale or hand-edited
value cannot make the story wrong.
**Operations that change what the story says at a position must ask whether
story descends from that position and is off screen.** Switching which take is
live, and editing a turn's text in place, both refuse in that situation rather
than act silently, because retained history must not be made to disagree with
itself in a way the user cannot see. See `STORY-BRANCH-SEMANTICS.md` §10 and
§14A.
### 8.8 Save Points, as implemented in M4
M4 added durable named Save Points and built nothing in §8 that was not already
there. This records what the milestone establishes as fact.
**A Save Point is a name and a coordinate.** The stored row holds the name, an
optional note, and `(branch, depth)` — the same pair §8.7 calls the head. It
holds no transcript, no state, no summary, no memory, and no branch contents.
`DATA-MODEL.md` §8 describes the pointer as naming a turn; the coordinate is
that turn's address, and `DATA-MODEL.md` §8's implementation note records why
this project uses the address rather than a row id: one coordinate can hold
several attempts at a turn, and a retry replaces the live one. "Turn 42 of this
line" survives a retry; a row id would pin a take the story no longer tells.
**Restore is head movement, and nothing else.** It resolves the coordinate,
refuses it if it no longer names a live turn, and then moves the head — the
depth through §8.7's single move operation, unchanged. The transcript, the
assembled context, the state and memory eligibility all arrive together because
they already read through the one capped lineage. There is no second restore
path, no state reconstruction, no memory pruning and no separate redo stack:
D13 is satisfied by the mechanism rather than by code written to satisfy it.
**A Save Point may name a position on a line the story has left.** Save Points
survive divergence, so this is reachable in ordinary use, and the depth half of
the head cannot reach a branch the current path does not contain. Restore
therefore moves the branch half as well when, and only when, the coordinate is
not on the path being read — the same single assignment a branch switch makes.
The distinction matters in the other direction too: a Save Point in a shared
prefix must *not* drag the reader onto the ancestor, because which continuation
follows that turn is exactly what the reader has already chosen.
**Restore never forks.** Moving the head is not a decision to abandon anything.
The first write below the restored head forks, through §8.7's existing check,
and the displaced future stays retained — so Redo still walks the original
continuation until the user writes something different, and stops offering it
once they have.
**Nothing removes a Save Point but the user.** There is no cleanup pass, and none
is wanted: a Save Point pointing behind the head, or into a line the story left,
is doing its job.
That rule is enforced against the one operation that could break it. Deleting a
branch deletes everything forked from it, so a Save Point naming a position in
that subtree would go too — silently, since the story is what the user asked to
delete. **The deletion is therefore refused while any Save Point names that
subtree**, and the refusal names them. The user deletes the Save Point
explicitly, which deletes no story, and then the branch. `STORY-BRANCH-SEMANTICS.md`
§19.1 states the rule; §28 already required a future cleanup feature to retain
paths a checkpoint references, and this is that requirement applied to the
deletion path that exists today.
## 9. Export / Import and Head Position
AI-DnD's current export carries branch information but reconstructs the imported head at the branch tip.
That is invalid after non-destructive Undo because an exported campaign can intentionally have:
```text
active head < retained tip
```
The production export format must preserve:
- active branch,
- active head turn/depth/coordinate,
- retained alternate/disposable history,
- checkpoints,
- state/history/provenance required for recovery.
For compatibility with earlier bundles, import may fall back to the retained tip only when no explicit active-head field exists.
Export/import regression tests must include an undone campaign and verify the imported story reopens at the exact exported head rather than silently redoing later turns.
### 9.1 As implemented in M3
The bundle carries the active head depth beside the active branch, and the import
honors it. This moved the head depth across the format's own rule about what a
bundle carries: a bundle carries what was *chosen* and recomputes what is
*derived*, and before M3 the head depth was genuinely derived — the newest row was
the only place a story could be read. It is a decision now, because the same tree
exports identically whether the user undid three turns or none, so the file has to
say.
A file that does not state a head is opened at the tip of its active branch. That
is a fallback only in form: such a file was written when the head could not be
anywhere else, so deriving the tip reproduces the position it actually recorded.
Pre-tree bundles take the same path. No format version bump was required, because
an absent field is unambiguous.
The head depth is validated before any row is written — a depth past the branch's
own retained story is a file disagreeing with itself and is refused, while a depth
*behind* it is the feature.
The bundle also carries which branches the story has left, and at what depth.
Every row of an abandoned line is exported either way, so without that metadata a
restored campaign could not distinguish abandoned history from active history —
which is precisely what a later cleanup or recovery feature has to select on.
### 9.2 Save Points in the bundle, as implemented in M4
Save Points are exported and imported with the campaign, which is `I04`. They
fall on the "chosen" side of §9.1's rule without argument: a position someone
named is not recoverable from the rows, since nothing about a turn records that
a player once bookmarked it.
No format version bump. A bundle written before M4 has no `checkpoints` key and
imports with none, which is what such a campaign had — the same unambiguous
absence §9.1 relies on for the head depth, and the same treatment the persona
block and the branch disposition received.
The head and the Save Points are independent, deliberately. An import opens the
campaign where `headDepth` says, never at a Save Point merely because the file
carries one: the bundle records where the story was being read and, separately,
which positions were named, and choosing between them is the user's to make
after the file is open.
A Save Point whose coordinate names no turn in the file is dropped rather than
refusing the import — the opposite of the head depth's treatment, and for a
stated reason. A misplaced head affects every read in the file; a bookmark
pointing outside the story affects only itself, and rejecting a whole campaign
to protect one bookmark would lose the story to save the pointer.
## 10. Authoritative Narrative State
### 10.1 Do not retain the RPG state protocol as the product model
AI-DnD's world-state machinery is useful evidence that state snapshots and rollback are structurally separable from RPG presentation, but the production state model must be genre-neutral.
Core concepts include:
- entities,
- facts,
- relationships,
- locations,
- possessions,
- conditions,
- organizations,
- story threads,
- scene state,
- chronology where needed.
### 10.2 Explicit typed state events
Use the ADR 010 model:
```text
model proposes explicit typed operation
-> schema validation
-> semantic/referential validation
-> accepted event(s)
-> state snapshot/cache
```
Prefer explicit absolute semantics for mutable values.
Examples:
```text
set_current_location
set_entity_status
set_possession
add_fact
invalidate_fact
add_relationship
end_relationship
open_story_thread
resolve_story_thread
set_scene
```
Avoid one generic relative-delta protocol whose numeric meaning depends primarily on prompt compliance.
### 10.3 Validation limitations
Typed events remove the delta/absolute ambiguity but do not guarantee semantic truth.
Validation should include deterministic checks where possible:
- event type allowlist,
- schema/type validation,
- entity/reference existence,
- impossible transitions where explicitly modeled,
- authority constraints,
- conflict handling,
- transaction integrity.
The accepted transcript remains available even if derived state extraction must be retried/repaired according to the final turn-acceptance workflow.
### 10.4 Hybrid storage
Selected direction:
> **validated state events + efficient current/historical snapshots/cache**
Events provide audit/reconstruction value. Snapshots/cache make normal reads, Undo/Redo, and context construction fast.
**Constraint added by M3 (see ADR 012).** The snapshot half is not an
optimization to be traded away. M3's head movement is a row lookup plus a
restore, which is why Undo, Redo and — later — Save Point restore cost the same
at any distance into a campaign's history. A state model that could only be
reconstructed by replaying events from the campaign opening would make every one
of those operations proportional to campaign length, on exactly the long
campaigns this product exists for. Whatever M5 introduces must keep the
authoritative state at a retained position efficiently recoverable — a per-node
snapshot, or an equivalent cache with the same property — while adding the typed
event model.
## 11. Context and Memory
Retain AI-DnD's useful lineage-aware memory foundation, but align it with the product authority model.
Narrator context is assembled in explicit layers:
```text
Narrator/system rules
Campaign profile
Global/explicit canon
Current authoritative state
Lineage-safe summary
Relevant older story memories
Relevant imported knowledge
Recent active-lineage turns
Current user input
```
Requirements:
- no abandoned future may appear in active recent history,
- no memory derived solely from an abandoned future may be retrieved,
- summaries are anchored to source lineage/turn ranges,
- derived memory/summary never becomes more authoritative than accepted state/canon,
- prompt snapshot records what was actually supplied,
- token budgets remain explicit and inspectable.
Phase 0B verified AI-DnD branch-scoped memory isolation against real local embeddings with a negative control. Preserve that property through the history rewrite.
## 12. Realistic-Context Model Testing
The Phase 0B referee failure appeared under full application context even though the same model followed the state protocol correctly in an isolated probe.
Therefore structured-output/state tests must include:
- realistic narrator/context length,
- representative state complexity,
- actual local models likely to be used,
- repeated runs rather than one clean prompt,
- malformed/incorrect semantic proposals,
- validation and recovery behavior.
Model capability recommendations are deferred until these measurements exist; this does not block the architecture.
## 13. Imported Knowledge Subsystem
Do not turn AI-DnD Story Cards into the production imported-knowledge store.
Story Cards may remain a useful reference or authored-rule mechanism, but the imported-knowledge requirements need a separate first-class subsystem with:
- source records,
- `.txt` / `.md` import,
- Canon / Reference / Inspiration classification,
- enable/disable/delete,
- source/version/hash provenance,
- chunk records,
- campaign scoping,
- local lexical index (prefer SQLite FTS5),
- local Ollama embeddings/semantic index where enabled,
- authority-aware hybrid retrieval,
- retrieval provenance,
- export/import support,
- no automatic URL/image fetching,
- imported content treated as data, never executable instructions.
If a future knowledge item is derived from story history rather than imported as global campaign material, it must carry lineage/source-turn information sufficient to avoid abandoned-path leakage.
## 14. Prompt and Provenance Inspection
Preserve and extend AI-DnD's Insights/context-snapshot capability.
For each narrator turn the system should be able to explain:
- narrator/system rules used,
- campaign/canon context,
- current authoritative state included,
- summary included,
- story memories retrieved,
- knowledge chunks retrieved,
- recent history included,
- user input,
- model/settings,
- state proposal,
- validation result,
- accepted events,
- source IDs/turn ranges where applicable.
## 15. Scene and Future Media Boundary
v1 does not require media generation.
It does require preserving scene/entity information so future providers do not have to infer continuity from the entire raw transcript.
Persist or derive a scene snapshot containing relevant fields such as:
- location,
- participants,
- significant objects,
- current actions,
- time/lighting/environment,
- mood,
- visual character/location profiles,
- continuity constraints,
- source turn range and lineage.
Future media coordinator consumes a normalized scene packet and records local asset provenance.
The story engine must remain fully functional with media disabled.
STT specifically follows:
```text
microphone/audio -> local STT -> editable draft -> normal user submission
```
STT never bypasses the ordinary authoritative story commit path.
## 16. Database Direction
SQLite remains the selected v1 authoritative store.
Reasons:
- already present in the selected base,
- local and single-user friendly,
- transactional,
- portable,
- supports FTS5,
- compatible with backup/export tooling,
- no external service required.
Remove Postgres/Neon support from the production fork unless a later explicit requirement reverses this decision.
The exact physical schema may evolve through migrations; the conceptual model is in `DATA-MODEL.md`.
## 17. Transaction Boundaries
Where practical, one accepted turn should atomically establish:
- accepted user input/narration relationship,
- turn/lineage identity,
- active-head advancement,
- validated authoritative state events,
- resulting state snapshot/cache,
- core prompt/model provenance needed for recovery/audit.
Derived work such as embeddings, memory extraction, summary generation, and future media jobs may occur separately, but failure must not corrupt the authoritative commit.
## 18. Testing Strategy
Use AI-DnD's inherited tests as a foundation, then rewrite/add tests around product semantics.
Required categories:
- offline startup/story use,
- no runtime remote assets/tokenizer fetch,
- same-host and trusted-LAN Ollama endpoint handling,
- turn persistence/restart,
- failed generation atomicity,
- non-destructive Undo/Redo,
- divergence after Undo,
- retry/take retention,
- named checkpoint restore,
- branch/lineage state reconstruction,
- abandoned-history memory/summary isolation,
- active-head export/import round trip,
- generic narrative state event validation,
- realistic-context structured state extraction,
- imported knowledge authority/provenance/isolation,
- prompt/context inspection,
- fantasy + science-fiction genre neutrality,
- 100-turn/long-run acceptance,
- future-media schema compatibility.
Acceptance-test IDs in `V1-ACCEPTANCE-TESTS.md` are the black-box release contract.
### 18.1 Wiring rule, from the M2 regressions
M2 shipped two defects that a 604-test green suite did not see: a removed
`Settings` attribute left two provider factories raising `AttributeError` inside
a background task, and a newly added timeout setting was stored, validated,
exposed and rendered without ever being passed to the provider that needed it.
Both were invisible because the tests at that boundary were mocks.
> **When removing a setting, attribute or dependency, test at least one real
> consumer construction path. When adding a setting, test that the configured
> value reaches the component that uses it. A green suite built entirely around
> mocks at that boundary is insufficient evidence.**
The corollary is where to look: subtractive changes and plumbing changes fail in
background and fire-and-forget paths, which are exactly the paths that report
nothing when they break.
## 19. Removal / Migration Strategy From Upstream
Production migration should be incremental and test-gated rather than a broad rewrite.
Remove or replace in controlled milestones:
1. runtime external dependency leaks,
2. hosted/multi-user/auth/demo/analytics/cloud/Postgres/QuickJS surfaces,
3. destructive Undo/no-Redo behavior,
4. RPG-specific state/referee protocol and UI assumptions,
5. Story Card assumptions where they conflict with the new knowledge subsystem.
Preserve upstream provenance and license notices.
Do not mechanically merge the ai-adventure or Open Dungeon repositories into the fork.
## 20. Deferred Questions That Do Not Block v1 Architecture
The following are implementation/release measurements, not unresolved foundational choices:
- which narrator/state models should be recommended to users,
- exact embedding model recommendation,
- performance of multi-hour stories,
- concurrency beyond the single-user turn lock,
- which future local image/video/TTS/STT provider is selected,
- abandoned-history cleanup policy/UI after v1.
## 21. Technical Design v1.0 Exit Status
Phase 0 has resolved the foundational choices required for v1.0:
- base repository selected,
- browser/backend stack selected,
- SQLite selected,
- non-destructive history/head model demonstrated,
- typed narrative-state-event direction selected,
- Ollama validated on local infrastructure, including the required trusted-LAN deployment mode,
- memory lineage behavior validated,
- imported-knowledge architecture selected,
- local-only hardening scope identified,
- export/import active-head defect understood,
- future media boundary retained,
- production milestone sequence defined in `BUILD-MILESTONES.md`.
This technical design is therefore the implementation baseline unless revised by a later ADR.