M10: the seam for media, and no media
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint + build (push) Canceled after 0s
CI / Docker image builds (push) Canceled after 0s

The media extension contract asks for a scene snapshot a future image or video
provider could be handed: location, who is present, what they hold, what must
stay true, and where in the story it sits. Building one was the milestone's
obvious first task, and it was the wrong one. That snapshot has existed since
M5. `narrative_state["scene"]` holds the summary, the location, the cast and the
coordinate it was written at; a validated `set_scene` event writes it, every
position snapshots it, and every head move restores it. It survives Undo, Redo,
Retry, divergence, Save Point restore and a process restart because it is the
authoritative state rather than a copy of it.

So there is no scenes table here. A second scene store would have been a second
answer to "where is the story now", with its own lineage rules to get wrong —
and the lineage rules are the expensive part, which is the argument for reusing
the ones that already work rather than against it. The Scene Packet is derived
on read, and its identity is computed from the campaign and the position rather
than allocated: the same position yields the same id in another process, after a
restart, and after the packet is thrown away and rebuilt, with no row to keep in
step. That is the part of a future media_assets table that would be expensive to
retrofit, so it is fixed now even though the table is not built.

One table, then: visual_profiles, the only thing the contract's scene list asks
for that nothing already stored. Campaign-scoped and not per-position, because a
character does not change appearance when the story forks — a reader who
diverged would otherwise lose their cast, and the same descriptors would land in
every per-position snapshot, measured at 245 copies of 367 bytes in a 120-turn
campaign to say something that never varies. Keyed by the M5 entity key rather
than a new identity namespace, and one table for characters, locations and items
alike, because a location is an entity with a type and splitting them would
reintroduce the genre shape M5 spent a milestone removing.

What the packet leaves out is the more interesting half. Not the transcript, and
not imported knowledge — none of it, not merely the sources marked hidden. The
rule is what the story established at this position, not everything the narrator
was told, and drawing it by class is what makes it hold for a secret nobody
thought to mark. A hidden Canon source proves it, with a positive control
showing the narrator did receive the sentinel the packet does not carry. Once a
validated event puts the observer in the room, the observer is in the packet:
that is no longer narrator-only knowledge, and a packet that hid it would be
hiding the story from itself.

The providers are contracts and nothing else. Protocols for image, video, audio,
speech and transcription, an empty registry, no adapter, no dependency, no
socket, and no media setting to point anywhere — a setting that exists can be
pointed at a cloud by mistake. A future provider endpoint must be loopback,
stricter than narration's trusted-LAN allowance, because a picture of a scene
carries the scene with it. Transcription returns an editable draft with no
commit method, so STT structurally cannot bypass the authoritative path.

Nothing here can write the story. Not by convention: no module under media/
imports the code that writes state, no media event type exists in the state
vocabulary, and every test in the authority suite compares the authoritative
document byte for byte either side of a media operation — including one where a
provider insists Alice is in a red coat in a corridor, and the campaign goes on
disagreeing.

One defect, found by the milestone's own tests. M10 first added a migration
creating an index that create_all already builds from the column, so an upgraded
database ended up with two indexes and a fresh install with one. Comparing the
two schemas is what caught it; neither database examined alone would have. The
migration is gone rather than renamed, and the right number of migrations for a
new table whose indexes are declared on its columns is zero.

Backend 1,191 passed / 14 skipped / 0 failed, 89 of them M10's. Frontend 145
passed. Lint, production build and Docker build clean. No frontend file changed:
M10 adds no reader-facing surface, and ordinary play — turns, state, memory,
knowledge, Undo, Redo, Retry, Save Point restore, restart — runs with no media
configuration, no warning, no connection attempt and no media row written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
This commit is contained in:
JesseMarkowitz
2026-09-07 03:41:04 -04:00
co-authored by Claude Opus 5
parent 44edece67e
commit 1013c94eb1
27 changed files with 5235 additions and 23 deletions
+16
View File
@@ -143,6 +143,18 @@ outbound request, so a database edited by hand or a hostname that starts
resolving somewhere new cannot turn a local install into an exfiltration path.
There is no setting to relax it.
### A future media provider would be held to a stricter rule
The same file decides, plus one extra condition. A media endpoint — a local image
or speech generator, when one is eventually supported — must be **loopback**, not
merely on your LAN (`backend/app/media/providers.py`,
`endpoint_rejection_reason`). A picture of a scene carries the scene with it, and
a GPU that renders your campaign is a machine you are sitting at.
Nothing to configure today: no media provider ships, the registry is empty, and
there is deliberately no media endpoint setting to fill in. The rule exists so
that whoever adds the first provider finds it already there.
### Same host (the default)
```text
@@ -223,6 +235,10 @@ Fourteen backend tests skip without something the machine may not have: seven
need a second machine or an environment the suite cannot create, and the rest
are the real-model tests below.
The suite takes about fifteen minutes. Several files spawn genuine server
processes — a restart is only evidence if the process really went away — and
those dominate the wall clock.
### The frontend component suite
M8 added one, because until M8 there was none — the browser was covered by real
+3 -2
View File
@@ -274,7 +274,7 @@ player input
```
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ routers/ scenarios, adventures, knowledge, story cards, chat, settings, debug
├─ models.py SQLAlchemy: Scenario, Adventure, Branch, Action, StoryCard, Settings, Memory, KnowledgeSource
├─ models.py SQLAlchemy: Scenario, Adventure, Branch, Action, StoryCard, Settings, Memory, KnowledgeSource, VisualProfile
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (92 and counting)
├─ endpoints.py the inference-endpoint address policy
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
@@ -288,6 +288,7 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ memorybank.py auto-summarization + embedding retrieval
├─ knowledge/ the imported library: import, chunk, FTS5, embed, rank, inject
├─ bundle.py the export/import formats: v3, and readers for v2 and v1
├─ media/ the future-media seam: scene packets, visual profiles, provider contracts
├─ backup.py a verified whole-database copy, via SQLite's backup API
├─ providers/ OpenAI-compatible adapter, streaming
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
@@ -298,7 +299,7 @@ development, Vite proxies `/api` to FastAPI.
## Tests
920 backend tests: unit tests plus full HTTP integration through the real turn engine, with
1,191 backend tests: unit tests plus full HTTP integration through the real turn engine, with
the model provider mocked. They run with no route to the Internet, which is a requirement
rather than a convenience — an offline claim proved on a machine that has been online once
proves nothing. A further handful need a real local model and skip without one; they exist
+126
View File
@@ -170,6 +170,7 @@ from .context import cursors, lineage
from .knowledge import chunking as knowledge_chunking
from .knowledge import classes as knowledge_classes
from .knowledge import importer as knowledge_importer
from .media import profiles as visual_profiles
from .narrative import model as narrative_model
#: What `export` writes. The family name is inherited from the production base
@@ -322,6 +323,30 @@ def export(db: Session, adventure: models.Adventure) -> dict:
# what decides eligibility (`CONTEXT-AND-MEMORY.md`; E03), so the row
# travels and its vectors do not.
"summaries": [_exported_summary(s, local) for s in adventure.summaries],
# M10. How the campaign's entities look.
#
# **Chosen**, by the rule at the top of this module: a reader wrote
# these, and nothing in the campaign can recompute them — the state
# document records what an entity *is*, never what it looks like. A
# campaign that arrived without them would have lost the descriptions
# its owner wrote and there would be no way to tell.
#
# Campaign-scoped and therefore carrying no coordinate, which is the one
# thing that makes this section shaped differently from every other list
# here. A profile does not belong to a position (`models.VisualProfile`),
# so there is no branch to remap and nothing to check against the tree.
#
# **No format bump.** Applying M9's own test — does an absent key create
# an ambiguity about what an older file could record? — the answer is
# no. A v3 file with no `visualProfiles` is unambiguous in the way a v2
# file with no `contextSnapshot` was not: appearance is not something a
# campaign has by default and then loses in the writing, it is something
# a reader adds. Absent means the campaign had none, which is exactly
# what it means for `checkpoints` before M4 and `knowledge` before M7,
# and both of those were added without a bump for the same reason.
"visualProfiles": [
_exported_visual_profile(v) for v in adventure.visual_profiles
],
# M5/M9. The audit half of the hybrid. `DATA-MODEL.md` §17 keeps the
# events for audit and the snapshots for restore, and version 2 carried
# only the snapshots — so a moved campaign could be read at any position
@@ -371,6 +396,23 @@ def _exported_source(source: models.KnowledgeSource) -> dict:
}
def _exported_visual_profile(profile: models.VisualProfile) -> dict:
"""One visual profile, as it goes into the file.
The entity key travels as itself. It is a key inside the campaign's own
state document, which travels in the same file, so it needs no translation —
unlike a branch number or a knowledge source id, both of which name rows
whose identity is local to a database.
"""
return {
"entityKey": profile.entity_key,
"descriptors": profile.descriptors or {},
"features": profile.features or [],
"styleNotes": profile.style_notes or "",
"createdAt": profile.created_at.isoformat() if profile.created_at else None,
}
def _exported_summary(summary: models.Summary, local: dict[int, int]) -> dict:
"""One summary, with the coordinate that decides whether it is eligible."""
return {
@@ -752,6 +794,10 @@ def plan(bundle: dict, version: str) -> dict:
# could not carry any, and the import does not invent an audit trail to
# fill the gap.
"summaries": _planned_summaries(bundle, len(branches)) if tree else [],
# M10. Empty for every file written before it, which for a
# campaign-scoped description means "nobody wrote one" rather than
# "the format could not say".
"visualProfiles": _planned_visual_profiles(bundle) if tree else [],
"proposals": _planned_proposals(bundle, len(branches), by_id),
"events": _planned_events(bundle, len(branches), by_id),
}
@@ -888,6 +934,59 @@ def _planned_summaries(bundle: dict, branches: int) -> list[dict]:
return out
def _planned_visual_profiles(bundle: dict) -> list[dict]:
"""The visual profiles in a bundle, checked and normalised.
A malformed profile is **dropped rather than refused**, and it is worth
saying why this lands on the opposite side of the line from a knowledge
source, which refuses.
An imported Canon file that quietly did not arrive is a campaign whose
narrator has silently stopped being told the rules, with nothing on screen
to notice. A visual profile that did not arrive costs a description of how
somebody looks: nothing reads it during play, no prompt changes, no state
moves, and the reader can see at a glance that it is missing because the
profile list is the surface it appears on. Refusing a whole campaign to
protect a description would trade the story for the caption.
Bounds are reused from `media.profiles` rather than restated, so a file
cannot carry a profile the API would have refused to create.
"""
raw = bundle.get("visualProfiles")
if not isinstance(raw, list):
return []
out: list[dict] = []
seen: set[str] = set()
for entry in raw:
if not isinstance(entry, dict):
continue
key = entry.get("entityKey")
if not isinstance(key, str) or not key.strip():
continue
key = key.strip()[:visual_profiles.MAX_KEY]
# One profile per entity is the model's own uniqueness rule; a file
# naming the same entity twice would violate it on write, so the first
# is kept and the rest dropped rather than raising a constraint error
# halfway through the import.
if key in seen:
continue
seen.add(key)
try:
out.append({
"entity_key": key,
"descriptors": visual_profiles._checked_descriptors(
entry.get("descriptors")),
"features": visual_profiles._checked_features(
entry.get("features")),
"style_notes": visual_profiles._checked_notes(
entry.get("styleNotes")),
"created_at": _as_time(entry.get("createdAt")),
})
except visual_profiles.ProfileError:
continue
return out
def _planned_proposals(
bundle: dict, branches: int, by_id: dict[int, int]
) -> list[dict]:
@@ -1353,6 +1452,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> dict:
_write_checkpoints(db, adventure, story["checkpoints"], ids)
_write_anchors(adventure, story, ids)
_write_summaries(db, adventure, story.get("summaries") or [], ids)
_write_visual_profiles(db, adventure, story.get("visualProfiles") or [])
_write_state_history(db, adventure, story, ids, rows)
sources = _write_knowledge(db, adventure, story.get("knowledge") or [])
# Last, because it needs both halves: the nodes carrying the snapshots and
@@ -1492,6 +1592,32 @@ def _write_summaries(
db.add(summary)
def _write_visual_profiles(
db: Session, adventure: models.Adventure, specs: list[dict]
) -> None:
"""Restores the campaign's visual profiles.
No entity check on the way in, unlike `media.profiles.set_profile`. The
check there catches a typo against the campaign the reader is looking at; on
an import the state document arrives in the same file, so a profile naming
an entity the file also carries is correct by construction, and one naming
an entity that only exists on a branch this campaign has left is still worth
keeping — the description is about how something looks, and the entity may
become reachable again.
"""
for spec in specs:
profile = models.VisualProfile(
adventure_id=adventure.id,
entity_key=spec["entity_key"],
descriptors=spec["descriptors"],
features=spec["features"],
style_notes=spec["style_notes"],
)
if spec["created_at"] is not None:
profile.created_at = spec["created_at"]
db.add(profile)
def _write_state_history(
db: Session, adventure: models.Adventure, story: dict, ids: list[int],
rows: list[models.Action],
+66
View File
@@ -0,0 +1,66 @@
"""M10: the seam a future media provider plugs into, and nothing behind it.
This package is **readiness, not media**. Nothing here generates an image, a
video, audio, speech or a transcription; nothing here opens a socket; nothing
here is required for the storyteller to run. A campaign plays exactly as it did
in M9 with none of this configured, which is M10's central acceptance
condition — see `test_m10_no_media.py`.
## What M10 found already built, and therefore did not build again
The largest finding of the milestone is how little of it needed inventing.
`MEDIA-EXTENSION-CONTRACT.md` §5 asks the story system to persist a structured
scene snapshot with a campaign, a lineage, a source position, a location and the
characters present. **All of that already exists**, and has since M5:
state["scene"] = {"summary": …, "location": <entity key>,
"present": [<entity keys>],
"at": {"branch_id": …, "depth": …}}
written only by the validated `set_scene` typed event (ADR 010), snapshotted per
node in `actions.narrative_state_after` (M5), restored on every head movement by
`attempts.restore_state` (M3/M4), and carried per position in the M9 v3 bundle.
So it is already authoritative, already lineage-safe, already survives Undo,
Redo, Save Point restore, divergence and restart, and already round-trips into a
clean data directory.
Building a `scenes` table beside that would have been a second representation of
information the application already stores authoritatively — the one thing the
M10 brief forbids — and it would have needed its own lineage rules, its own
restore path and its own bundle carriage, each a chance to disagree with the
state document. **So M10 stores no scene rows.** It reads the scene that is
already there.
## What was actually missing
Three things, and this package is each of them:
* `profiles.py` — **visual profiles.** Stable descriptors for how an entity
*looks*, which nothing recorded. Campaign-scoped rather than per-position,
because a character does not change appearance when the story forks (K02, K03).
* `packet.py` — **the Scene Packet.** A bounded, provider-neutral,
hidden-information-safe view of one scene, built on demand from authoritative
state. Persisted nowhere, because it is a pure function of things that are.
* `providers.py` — **the provider contracts.** Types and protocols for image,
video, audio, TTS and STT, with no provider vocabulary anywhere in them, plus
the loopback-only endpoint rule the media contract asks for.
## The authority direction, which never reverses
accepted story -> narrative state -> scene packet -> future provider
Every arrow points away from authority. A visual profile is not a story fact; a
scene packet is a read; a future asset would be a depiction. Nothing in this
package writes `narrative_state`, emits a state event, or moves the head — and
`test_m10_authority.py` asserts that by running each operation and comparing the
authoritative document byte for byte either side.
That is the rule `MEDIA-EXTENSION-CONTRACT.md` §35 and §49 state, and the reason
it is enforced structurally rather than by convention: the only code that may
change authoritative state is the M5 event pipeline, and nothing here imports
it.
"""
from . import packet, profiles, providers
__all__ = ["packet", "profiles", "providers"]
+328
View File
@@ -0,0 +1,328 @@
"""M10: the Scene Packet — one accepted scene, bounded, for a future provider.
`MEDIA-EXTENSION-CONTRACT.md` §10-12 asks for a normalised, provider-independent
description of a scene, and asks explicitly that a provider **not** normally
receive the campaign transcript. This module builds that description.
## It is constructed, never stored
A packet is a pure function of things that are already persisted: the
authoritative state document at a position, the entity records inside it, and
the campaign's visual profiles. Storing one would create a second copy of all of
that, which could then disagree with the first — and the packet has no field the
source of truth does not already hold.
So there is no `scene_packets` table, nothing to migrate, nothing to keep in
step with the head, and nothing to carry in a bundle. Rebuilding it costs one
state read and one profile query. That is the same reasoning M9 applied to the
FTS index and the knowledge passages, applied to a smaller thing.
## Scene identity, without a scenes table
`MEDIA-EXTENSION-CONTRACT.md` §10 shows a `scene_id`, and the M10 brief asks
that a future asset be able to name unambiguously:
campaign -> lineage/story position -> source turn or turn range -> scene
That is a **coordinate**, and the application already has one. So the identity
is derived rather than allocated:
c<adventure>:b<branch>:<start>-<end>
Two properties follow, and both matter more than a surrogate key would have:
* it is **stable** — the same scene yields the same id on any machine, before
and after an export, without a row having to travel;
* it is **resolvable** — a future asset holding this string can be turned back
into the exact accepted position it depicts, with no lookup table.
A surrogate `scene_id` would have needed a table, a lineage column, a restore
path and bundle carriage, all to name something the coordinate already names.
## Ranges, because a video is not a turn
`build` takes a range, not a position. §30-31 of the contract describe a video
covering several accepted turns, and the M10 brief is explicit that neither
"one turn == one scene" nor "one scene == one asset" may be assumed.
So `start` and `end` are depths on one branch, the identity carries both, and a
single-turn image is the case where they are equal rather than a different kind
of request. Several future assets may name the same identity; nothing here
allocates or records them, so nothing constrains how many there are.
## What is deliberately not in a packet
**The transcript.** Not a summarised version of it either. The packet carries
the scene's own summary — the one sentence the story itself accepted through
`set_scene` — and the entities present. A provider that needs to depict a room
does not need to have read the campaign.
**Imported knowledge, of any class.** Not canon, not reference, not
inspiration, and emphatically not a narrator-only source. This is the hidden
information boundary and it is drawn structurally: this module never reads
`knowledge_sources`, so there is no filter to get wrong and no marker to
overlook. A secret reaches a packet only if the *story* put it into accepted
state through a validated event — which is the correct rule, because at that
point it is something that happened rather than something the narrator knows.
**Memories and summaries.** Derived narrative text about the campaign's past,
which is not what depicting a present moment needs.
**Facts, relationships and threads.** These are the campaign's reasoning about
itself. A `continuity_constraints` list carries the few that bear on depiction —
what a character is holding, where they are — and nothing else.
The result is that the honest answer to "what could leak through a packet" is
"what the accepted scene contains", which is what a picture of that scene would
show anyway.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from .. import models
from ..context import lineage
from ..narrative import model as narrative_model
from ..narrative import store as narrative_store
from . import profiles as visual_profiles
#: How many entities one packet will describe. A scene is a moment with people
#: in it; a request naming two hundred is a runaway state document rather than a
#: picture, and the bound keeps a future provider's prompt finite.
MAX_CHARACTERS = 24
MAX_OBJECTS = 24
MAX_CONSTRAINTS = 24
def scene_id(adventure_id: int, branch_id: int | None, start: int, end: int) -> str:
"""The derived, stable identity for one scene. See the module docstring."""
branch = branch_id if branch_id is not None else 0
return f"c{adventure_id}:b{branch}:{start}-{end}"
def parse_scene_id(value: str) -> dict | None:
"""Turns a scene identity back into the coordinate it names, or `None`.
The half that makes the derived identity worth having: a future asset
holding this string can be resolved to an accepted position without a table.
"""
try:
campaign, branch, span = str(value).split(":")
start, end = span.split("-")
return {
"adventure_id": int(campaign.lstrip("c")),
"branch_id": int(branch.lstrip("b")),
"start": int(start),
"end": int(end),
}
except (ValueError, AttributeError):
return None
def build(
db: Session,
adventure: models.Adventure,
*,
start: int | None = None,
end: int | None = None,
) -> dict:
"""The Scene Packet for a range of accepted story on the active branch.
Defaults to the scene at the active head, which is the ordinary case: an
image of what is happening now. `start` and `end` are depths on the active
branch; passing both describes a stretch, which is what a future video
would ask for.
Reads. Writes nothing, and cannot: this module imports no writer, emits no
event and does not touch the head. `test_m10_authority.py` asserts the
authoritative document is byte-identical either side of a build.
"""
state = narrative_store.current(adventure)
scene = state.get("scene") if isinstance(state.get("scene"), dict) else {}
branch_id = adventure.head_branch_id
head_depth = adventure.head_depth
# The scene's own coordinate is the position `set_scene` last ran at, which
# is where the depiction belongs. It can sit behind the head — the story may
# have moved on without re-establishing the scene — and that is correct: the
# picture is of the moment the scene was set, not of a later turn that did
# not change it.
at = scene.get("at") if isinstance(scene.get("at"), dict) else {}
scene_branch = at.get("branch_id") if at.get("branch_id") is not None else branch_id
scene_depth = at.get("depth") if _is_int(at.get("depth")) else head_depth
first = start if _is_int(start) else scene_depth
last = end if _is_int(end) else max(first, scene_depth)
if last < first:
first, last = last, first
profiles = visual_profiles.by_key(db, adventure)
location_key = scene.get("location") if isinstance(scene.get("location"), str) else None
present = [k for k in (scene.get("present") or []) if isinstance(k, str)]
return {
"scene_id": scene_id(adventure.id, scene_branch, first, last),
"campaign": {"id": adventure.id, "title": adventure.title},
# Where in the story this is, in the vocabulary the application already
# uses internally. A future provider does not read these; a future
# coordinator resolving an asset back to its source does.
"turn_range": {"branch_id": scene_branch, "start": first, "end": last},
"lineage": _lineage_of(db, adventure),
"location": _entity_view(state, profiles, location_key),
"characters": [
view for key in present[:MAX_CHARACTERS]
if (view := _entity_view(state, profiles, key)) is not None
],
"objects": _objects(state, profiles, present, location_key),
"action_summary": str(scene.get("summary") or ""),
"continuity_constraints": _constraints(state, present, location_key),
# Present, empty, and deliberately so — see `_ambience`.
"ambience": _ambience(scene),
"source": {
# What produced this, so a future asset's provenance can say which
# build's rules bounded the packet it was made from.
"packet_version": PACKET_VERSION,
"head_depth": head_depth,
},
}
#: The packet's own shape version. A future provider adapter can branch on it if
#: the packet gains fields; nothing in the story engine reads it.
PACKET_VERSION = 1
def _lineage_of(db: Session, adventure: models.Adventure) -> list[dict]:
"""The capped lineage this scene sits on, as provenance.
Read through `lineage.path_of`, the same helper every story read uses, so a
packet cannot describe a position the story could not. M10 builds no media
head: there is one head, and this follows it.
"""
try:
path = lineage.path_of(db, adventure)
except Exception: # noqa: BLE001 - a packet is a read; it does not raise
return []
entries = getattr(path, "entries", None)
if not entries:
return []
return [
{"branch_id": branch_id, "through_depth": cap}
for branch_id, cap in entries
]
def _entity_view(state: dict, profiles: dict, key: str | None) -> dict | None:
"""One entity as a packet describes it: what it is, plus how it looks."""
if not key:
return None
found = narrative_model.entity(state, key)
if found is None:
return None
return {
"key": key,
"name": narrative_model.entity_name(state, key),
"type": found.get("type") or "other",
"status": found.get("status") or "active",
"description": found.get("description") or "",
# `None` rather than an empty profile, so a provider can tell "nobody
# said how this looks" from "somebody said it looks like nothing".
"visual_profile": profiles.get(key),
}
def _objects(
state: dict, profiles: dict, present: list[str], location_key: str | None
) -> list[dict]:
"""The things visibly in the scene, from what the present entities hold.
Possession is the only relation in the state document that says an object is
*somewhere*, so it is the honest source for "what would be in the picture".
An item nobody in the scene is carrying is not depicted, which is the same
rule a reader would apply looking at the room.
"""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return []
holders = set(present) | ({location_key} if location_key else set())
out: list[dict] = []
for item_key, holder in possessions.items():
if holder not in holders or not isinstance(item_key, str):
continue
view = _entity_view(state, profiles, item_key)
if view is None:
continue
view["held_by"] = holder
out.append(view)
if len(out) >= MAX_OBJECTS:
break
return out
def _constraints(
state: dict, present: list[str], location_key: str | None
) -> list[str]:
"""The few facts that bear on depicting *this* scene, as sentences.
Deliberately narrow. The state document's `facts` list is the campaign's
reasoning about itself and most of it has nothing to do with a picture;
forwarding all of it would make the packet a state dump with a different
name, and would be the route by which something the scene has not exposed
reached a provider.
So only two kinds are carried: where the present entities are, and what they
are holding. Both are already visible in the scene by construction.
"""
out: list[str] = []
for key in present:
found = narrative_model.entity(state, key)
if found is None:
continue
name = narrative_model.entity_name(state, key)
status = found.get("status")
if status and status != "active":
out.append(f"{name} is {status}.")
if len(out) >= MAX_CONSTRAINTS:
return out
possessions = state.get("possessions")
if isinstance(possessions, dict):
for item_key, holder in possessions.items():
if holder not in present:
continue
out.append(
f"{narrative_model.entity_name(state, holder)} is carrying "
f"{narrative_model.entity_name(state, item_key)}."
)
if len(out) >= MAX_CONSTRAINTS:
break
return out
def _ambience(scene: dict) -> dict:
"""Time of day, lighting and mood — present in the shape, empty in v1.
`MEDIA-EXTENSION-CONTRACT.md` §5 lists these among a scene snapshot's
conceptual fields, and M10 **does not** add them to the `set_scene` event
that would establish them.
That is a deliberate deferral rather than an oversight. Adding them would
mean extending M5's typed-event vocabulary, which means teaching the
narrator to emit them, which means changing the prompt — and M10's central
acceptance condition is that ordinary story flow is *unchanged*. Buying
three optional fields at the price of touching every narration was the wrong
trade for a milestone whose deliverable is a seam.
So the keys are here and are `None`, read from the scene document if a later
milestone starts recording them. A provider adapter written today against
this shape keeps working when they arrive.
"""
return {
"time_of_day": scene.get("time_of_day") or None,
"lighting": scene.get("lighting") or None,
"mood": scene.get("mood") or None,
}
def _is_int(value) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
+220
View File
@@ -0,0 +1,220 @@
"""M10: reading and writing how an entity looks.
`models.VisualProfile` carries the design reasoning — why these rows are
campaign-scoped rather than per-position, why there is one table for characters,
locations and items, and why nothing here is story state. This module is the
narrow set of operations on them, and its own job is to make two things true:
* **a profile can only name an entity the campaign actually has**, so a typo
produces an error rather than a row describing nobody;
* **writing one changes nothing authoritative**, which is guaranteed by this
module not importing anything that could.
## Why the entity is checked against the current head
An entity key means something only in a state document, and a campaign has a
different document at every position. The check is made against the state at
the **active head** — the story the reader is on — for the same reason
`narrative/validate.py` resolves its `refs` there: it is the only position the
reader is looking at, and a key that means nothing there is a mistake, not a
branch subtlety.
The row that results is campaign-scoped anyway, so a profile written while
standing on one branch is visible from every branch. That asymmetry is
deliberate and is the continuity the profile exists for: the check is *"does
this name someone"*, and the storage answers *"what do they look like"*, which
does not vary by path.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models
from ..narrative import model as narrative_model
from ..narrative import store as narrative_store
#: How many descriptors one profile may carry, and how long each may be. A
#: profile is a handful of stable traits, not a document: the bound exists so a
#: future provider's prompt cannot be grown without limit through this door, and
#: so one campaign cannot store an essay per entity.
MAX_DESCRIPTORS = 40
MAX_FEATURES = 40
MAX_VALUE = 400
MAX_STYLE_NOTES = 2_000
MAX_KEY = 200
class ProfileError(ValueError):
"""A visual profile could not be written, and why."""
def entity_exists(state: dict, entity_key: str) -> bool:
"""Whether the state document names this entity."""
return narrative_model.entity(state, entity_key) is not None
def set_profile(
db: Session,
adventure: models.Adventure,
entity_key: str,
*,
descriptors: dict | None = None,
features: list | None = None,
style_notes: str | None = None,
) -> models.VisualProfile:
"""Records how `entity_key` looks, creating or replacing the profile.
Replaces rather than merges. A profile is one answer to "what does this look
like", and merging would make it impossible to *remove* a descriptor — the
caller would be able to add "wearing a red coat" and never take it off,
which for continuity metadata is the wrong default. A caller that wants to
amend one reads it first.
Raises `ProfileError` if the campaign's state at the active head does not
name the entity, or if the profile is malformed. It writes nothing in either
case, and it writes nothing to `narrative_state` in any case.
"""
key = _checked_key(entity_key)
state = narrative_store.current(adventure)
if not entity_exists(state, key):
raise ProfileError(
f"This campaign has no entity called {key!r}, so there is nothing "
f"for a visual profile to describe. Profiles attach to the "
f"campaign's own entities, not to names."
)
row = get_profile(db, adventure, key)
if row is None:
row = models.VisualProfile(adventure_id=adventure.id, entity_key=key)
db.add(row)
row.descriptors = _checked_descriptors(descriptors)
row.features = _checked_features(features)
row.style_notes = _checked_notes(style_notes)
return row
def get_profile(
db: Session, adventure: models.Adventure, entity_key: str
) -> models.VisualProfile | None:
return db.execute(
select(models.VisualProfile).where(
models.VisualProfile.adventure_id == adventure.id,
models.VisualProfile.entity_key == entity_key,
)
).scalars().first()
def all_for(db: Session, adventure: models.Adventure) -> list[models.VisualProfile]:
return list(db.execute(
select(models.VisualProfile)
.where(models.VisualProfile.adventure_id == adventure.id)
.order_by(models.VisualProfile.entity_key)
).scalars().all())
def by_key(db: Session, adventure: models.Adventure) -> dict[str, dict]:
"""Every profile in the campaign, keyed by entity, as plain dictionaries.
One query, because the Scene Packet needs several profiles at once and
fetching them per entity would be a query per character in the scene.
"""
return {row.entity_key: as_dict(row) for row in all_for(db, adventure)}
def as_dict(row: models.VisualProfile) -> dict:
"""One profile as it appears in a Scene Packet."""
return {
"descriptors": dict(row.descriptors or {}),
"features": list(row.features or []),
"style_notes": row.style_notes or "",
}
def delete_profile(
db: Session, adventure: models.Adventure, entity_key: str
) -> bool:
"""Removes a profile. Returns whether there was one.
Deleting a profile removes a *description*, never the entity: the entity
lives in the authoritative state document and nothing here can reach it.
"""
row = get_profile(db, adventure, entity_key)
if row is None:
return False
db.delete(row)
return True
# ------------------------------------------------------------- the checking
def _checked_key(entity_key) -> str:
if not isinstance(entity_key, str) or not entity_key.strip():
raise ProfileError("A visual profile has to name an entity.")
key = entity_key.strip()
if len(key) > MAX_KEY:
raise ProfileError(f"Entity keys are at most {MAX_KEY} characters.")
return key
def _checked_descriptors(descriptors) -> dict:
"""Trait -> value, both short strings.
Values are text rather than arbitrary JSON on purpose. A descriptor is
something a future provider will put in a prompt, and a nested structure
would either be flattened by whoever does that — inconsistently — or
smuggle a provider-shaped payload through a story-side field, which is the
boundary this package exists to keep.
"""
if descriptors is None:
return {}
if not isinstance(descriptors, dict):
raise ProfileError("`descriptors` must be a map of trait to value.")
if len(descriptors) > MAX_DESCRIPTORS:
raise ProfileError(
f"A profile may carry at most {MAX_DESCRIPTORS} descriptors."
)
out: dict[str, str] = {}
for trait, value in descriptors.items():
if not isinstance(trait, str) or not trait.strip():
raise ProfileError("Every descriptor needs a name.")
if not isinstance(value, str):
raise ProfileError(
f"The value for {trait!r} must be text — a profile describes "
f"how something looks, in words a person could read back."
)
if len(value) > MAX_VALUE:
raise ProfileError(
f"The value for {trait!r} is longer than {MAX_VALUE} characters."
)
out[trait.strip()[:MAX_KEY]] = value
return out
def _checked_features(features) -> list:
if features is None:
return []
if not isinstance(features, list):
raise ProfileError("`features` must be a list of short phrases.")
if len(features) > MAX_FEATURES:
raise ProfileError(f"A profile may carry at most {MAX_FEATURES} features.")
out = []
for feature in features:
if not isinstance(feature, str) or not feature.strip():
raise ProfileError("Every feature must be a non-empty phrase.")
if len(feature) > MAX_VALUE:
raise ProfileError(f"A feature is longer than {MAX_VALUE} characters.")
out.append(feature.strip())
return out
def _checked_notes(style_notes) -> str:
if style_notes is None:
return ""
if not isinstance(style_notes, str):
raise ProfileError("`style_notes` must be text.")
if len(style_notes) > MAX_STYLE_NOTES:
raise ProfileError(
f"Style notes are longer than {MAX_STYLE_NOTES} characters."
)
return style_notes.strip()
+332
View File
@@ -0,0 +1,332 @@
"""M10: what a future media provider must satisfy, and nothing that satisfies it.
No provider is implemented here, none is registered by default, and nothing in
this module opens a socket. What it defines is the shape of the boundary, so
that adding a real image, video, audio, TTS or STT provider later is writing an
adapter rather than editing the story engine.
## The rule these types exist to enforce
`MEDIA-EXTENSION-CONTRACT.md` §3: the Story Engine must not call ComfyUI, Stable
Diffusion, a video pipeline, a TTS engine or a third-party media API. It states
that as a recommendation; this module makes it structural. Everything crossing
the boundary is expressed in this vocabulary:
MediaKind image | video | audio | tts | stt
MediaRequest a scene packet, a kind, and neutral hints
MediaResult bytes-or-path, a type, and provenance
DraftTranscription STT's deliberately different answer (see below)
**No provider vocabulary appears anywhere in this file or in any story module.**
There is no workflow JSON, no sampler name, no CFG scale, no LoRA, no
`num_inference_steps`, no Whisper option and no voice id. A provider adapter
owns that translation, in its own package, and the story engine never learns it.
`test_m10_providers.py` greps the story modules for that vocabulary so the rule
cannot rot quietly.
## Why Protocols rather than base classes
A future adapter should not have to import from here to be usable — it should
merely have to *fit*. `typing.Protocol` gives a structural contract that a test
double satisfies as readily as a real ComfyUI adapter, which keeps the seam
honest: if the only way to satisfy the interface were to inherit from it, the
interface would be describing this codebase rather than the boundary.
## STT is deliberately shaped differently, and that is the point
Every other provider returns a `MediaResult` — a depiction of something the
story already established. STT returns a `DraftTranscription`, which is a
different type on purpose, because it flows the other way:
audio -> local STT -> draft text -> the reader edits it -> normal submission
`MEDIA-EXTENSION-CONTRACT.md` §24A states the rule as *"STT output is draft user
input, not an accepted story event."* A shared return type would have made it
possible to hand a transcription to something expecting a finished artefact, and
the asymmetry would have survived only as a comment. `DraftTranscription`
carries `editable = True` and has no path into the turn pipeline: the reader's
edited text enters through the ordinary action endpoint like anything they
typed, and is validated, refereed and snapshotted exactly the same way.
M10 implements no microphone capture and no transcription. The type boundary is
the deliverable.
## Endpoints: loopback only, and stricter than the narrator's on purpose
`endpoints.py` already decides which *inference* endpoints this product will
talk to, and allows an explicitly configured trusted LAN as well as loopback
(ADR 011). Media is not given that latitude. `MEDIA-EXTENSION-CONTRACT.md` §27
and §28 set the media default at loopback, with any future LAN extension
explicit and user-controlled — so `check_endpoint` below reuses the existing,
tested address machinery and then applies the stricter rule on top.
Reusing rather than reimplementing matters: a second endpoint validator would be
a second place for the policy to be wrong, and this one inherits the property
that makes the first one hard to talk around — it judges the address a host
actually resolves to, not the name.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from .. import endpoints
#: The kinds of media this architecture is required to accommodate. A string
#: enum rather than free text, so a typo is a failure here rather than a request
#: nothing will ever service.
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
TTS = "tts"
STT = "stt"
MEDIA_KINDS: tuple[str, ...] = (IMAGE, VIDEO, AUDIO, TTS, STT)
def is_media_kind(value) -> bool:
return isinstance(value, str) and value in MEDIA_KINDS
class MediaProviderError(RuntimeError):
"""A provider could not do what was asked.
Deliberately its own type, and deliberately not caught anywhere in the story
path: nothing in a turn calls a provider, so there is no code path where
this could reach an accepted narration. If a future coordinator catches it,
it does so on its own side of the boundary — a failed depiction must leave
the story exactly as it was (`MEDIA-EXTENSION-CONTRACT.md` §50).
"""
class EndpointRejected(endpoints.EndpointRejected):
"""A media endpoint outside the loopback-only media policy.
Subclasses the inference rejection so that a caller which already handles
"this endpoint is not allowed" keeps working, while a caller that wants to
tell the two policies apart still can.
"""
def endpoint_rejection_reason(url: str) -> str | None:
"""Why this URL may not be a media endpoint, or `None` if it may.
Two rules, in order, and the first is somebody else's:
1. the existing inference policy — an address in an allowed private network,
judged by resolution rather than by name (`endpoints.py`);
2. **and** loopback specifically, which is the media contract's stricter
default (§27, §28).
So a trusted-LAN address that an Ollama may legitimately use is refused here.
That is not an oversight: narrator inference is a deployment the user has
already reasoned about and configured, whereas a media endpoint is a new
surface with no v1 use, and the safe default for a surface nobody needs yet
is the narrowest one. A future milestone may widen it, explicitly and off by
default, which is what §27 requires of any such change.
"""
reason = endpoints.rejection_reason(url)
if reason is not None:
return reason
if not endpoints.is_loopback(url):
return (
"A media provider endpoint must be on this machine. "
f"{url!r} resolves somewhere else — media generation has no "
"trusted-LAN mode, and adding one would be an explicit, "
"off-by-default change rather than a setting."
)
return None
def check_endpoint(url: str) -> None:
"""Raises `EndpointRejected` unless `url` is an allowed media endpoint."""
reason = endpoint_rejection_reason(url)
if reason is not None:
raise EndpointRejected(reason)
# ----------------------------------------------------------------- the types
@dataclass(frozen=True)
class ProviderCapabilities:
"""What one provider can do, in neutral terms.
Deliberately small. `MEDIA-EXTENSION-CONTRACT.md` §25 shows a richer example
— seeds, reference images, inpainting — and M10 does not model those,
because every one of them is a guess until a provider exists to be asked.
What is here is what a coordinator would need in order to choose *whether*
to route to this provider at all; anything finer belongs to the adapter and
its own capability document.
"""
provider_id: str
kinds: tuple[str, ...] = ()
#: Free-form, provider-owned, and never interpreted by story code. It exists
#: so an adapter can advertise what it supports without this module growing
#: a field per feature the ecosystem invents.
details: dict = field(default_factory=dict)
def supports(self, kind: str) -> bool:
return kind in self.kinds
@dataclass(frozen=True)
class MediaRequest:
"""What a coordinator would hand a provider: a scene, a kind, and hints.
`scene` is a Scene Packet (`packet.build`) — a bounded description of one
accepted scene, not the transcript. That is the whole point of the packet
existing (`MEDIA-EXTENSION-CONTRACT.md` §12): a provider is given what it
needs to depict a moment and no more, which bounds prompt size, keeps
providers interchangeable, and means swapping one does not hand a new
process the campaign's history.
`hints` is provider-neutral and optional — an aspect ratio, a duration, a
count. It is **not** where a workflow graph or a sampler setting goes; those
belong to the adapter, which knows what it is talking to.
"""
kind: str
scene: dict
hints: dict = field(default_factory=dict)
def __post_init__(self):
if not is_media_kind(self.kind):
raise ValueError(
f"{self.kind!r} is not one of {', '.join(MEDIA_KINDS)}"
)
@dataclass(frozen=True)
class MediaResult:
"""What a provider hands back: a depiction, and where it came from.
Bytes *or* a path, never both, and the caller says which it wanted. Neither
is interpreted here; M10 registers no provider, so nothing constructs one of
these outside a test.
`provenance` carries the scene identity the request named, so that a future
asset can always be traced to the accepted position it depicts
(`MEDIA-EXTENSION-CONTRACT.md` §48). It is a record of what was asked for —
it does not make the depiction true.
"""
kind: str
media_type: str
provenance: dict = field(default_factory=dict)
data: bytes | None = None
path: str | None = None
details: dict = field(default_factory=dict)
@dataclass(frozen=True)
class DraftTranscription:
"""STT's answer, and deliberately not a `MediaResult`.
See the module docstring. This is **draft user input**: text the reader is
expected to read, correct and submit themselves. It is not an accepted turn,
not a state event, not canon, and it has no route into the story that the
reader's own typing does not also take.
`editable` is `True` and there is no constructor that sets it otherwise —
it is a statement about what this type *is* rather than a setting, and a
reader that finds it false has been handed something that is not a draft.
"""
text: str
editable: bool = True
confidence: float | None = None
details: dict = field(default_factory=dict)
# ------------------------------------------------------------- the protocols
@runtime_checkable
class MediaProvider(Protocol):
"""Anything that can depict an accepted scene.
One protocol covers image, video and audio because the boundary is the same
for all three: a bounded scene in, a depiction out, nothing written to the
story. What differs between them is entirely inside the adapter.
"""
def capabilities(self) -> ProviderCapabilities: ...
async def generate(self, request: MediaRequest) -> MediaResult: ...
@runtime_checkable
class SpeechProvider(Protocol):
"""Text to speech: still a depiction, of prose the story already accepted."""
def capabilities(self) -> ProviderCapabilities: ...
async def speak(self, text: str, hints: dict | None = None) -> MediaResult: ...
@runtime_checkable
class TranscriptionProvider(Protocol):
"""Speech to text, which runs the other way and returns a draft.
The signature is the asymmetry: it takes audio and returns
`DraftTranscription`, so no coordinator can hand its output to something
expecting a finished artefact, and nothing can mistake it for an accepted
turn.
"""
def capabilities(self) -> ProviderCapabilities: ...
async def transcribe(
self, audio: bytes, hints: dict | None = None
) -> DraftTranscription: ...
# -------------------------------------------------------------- the registry
#: Registered providers, by id. **Empty, and empty on purpose.**
#:
#: M10 ships no provider, so nothing is registered at import, nothing is
#: required at startup, and no configuration is read. `test_m10_no_media.py`
#: asserts this is empty after the application has been imported and a campaign
#: has been played — media readiness has to be inert until something explicitly
#: uses it.
_REGISTRY: dict[str, object] = {}
def register(provider_id: str, provider: object) -> None:
"""Makes a provider available to a future coordinator.
Exists to prove the claim in M10's Definition of Done — that a provider can
be added *without modifying story authority or history* — by being the only
thing an adapter has to call. Nothing in `app/routers`, `app/narrative`,
`app/context` or `app/tree` imports this module, so registering one cannot
reach them.
"""
if not isinstance(provider_id, str) or not provider_id.strip():
raise ValueError("a provider needs an id")
_REGISTRY[provider_id] = provider
def unregister(provider_id: str) -> None:
_REGISTRY.pop(provider_id, None)
def registered() -> dict[str, object]:
"""The registry, copied — callers must not mutate it in place."""
return dict(_REGISTRY)
def for_kind(kind: str) -> list[object]:
"""Every registered provider advertising `kind`. Empty in v1."""
out = []
for provider in _REGISTRY.values():
caps = getattr(provider, "capabilities", None)
if caps is None:
continue
try:
if caps().supports(kind):
out.append(provider)
except Exception: # noqa: BLE001 - a broken adapter is not this layer's
continue
return out
+28
View File
@@ -433,6 +433,34 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# Such a campaign opens with an empty library and needs no source to play.
(92, {"sqlite": fts.DDL,
"default": "-- FTS5 is SQLite-only; this build stores campaigns in SQLite"}),
# M10 adds **no migration**, and that is the whole of its schema story.
#
# `visual_profiles` is a new table, so `create_all` builds it on every path
# — fresh install, existing database, test setup — exactly as it did for
# `memories`, `branches`, `checkpoints`, `summaries` and the knowledge
# tables. Its one index is declared on the column (`index=True`) rather than
# in `__table_args__`, so `create_all` builds that too, which is what
# version 92's note above says about the M7 tables: when the index is on the
# column there is nothing left for a `CREATE INDEX` here to do.
#
# A version 93 was written here first, adding
# `ix_visual_profiles_adventure`. It was wrong, and the M10 suite's
# fresh-versus-upgraded comparison is what found it: an upgraded database
# ended up with that index *and* the `ix_visual_profiles_adventure_id` that
# `create_all` had already made, while a fresh install had only the latter.
# Two schemas that differ by which path the file took is the thing a
# migration exists to prevent, and the redundant index was the only
# difference between them.
#
# **No backfill, and there is nothing that could be backfilled.** A profile
# says what an entity looks like, and no existing column holds that: the
# narrative state records what entities *are* — type, status, description,
# location — and inventing an appearance from a description would be
# fabricating exactly the kind of visual detail
# `MEDIA-EXTENSION-CONTRACT.md` §37 says must never appear without the
# reader asking for it. An M9 campaign therefore opens with no profiles,
# which is what such a campaign had, and plays unchanged without any.
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+109
View File
@@ -246,6 +246,13 @@ class Adventure(Base):
cascade="all, delete-orphan",
order_by="KnowledgeSource.id",
)
# M10: how the campaign's entities look. Derived presentation metadata, not
# story state — see `VisualProfile`.
visual_profiles: Mapped[list["VisualProfile"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="VisualProfile.id",
)
class Branch(Base):
@@ -802,6 +809,108 @@ class KnowledgeEmbedding(Base):
chunk: Mapped[KnowledgeChunk] = relationship(back_populates="embedding")
class VisualProfile(Base):
"""M10: how one entity looks, so a future depiction can be consistent.
The only thing M10 persists, and the reason is that it was the only thing
the media contract asks for that nothing already stored. The scene snapshot
§5 asks for already exists as `narrative_state["scene"]` and has since M5;
building a second one beside it would have been a duplicate representation
with its own lineage rules to get wrong.
## Not story state, and structurally so
A visual profile is **presentation metadata**. Nothing here is a fact the
story established: `MEDIA-EXTENSION-CONTRACT.md` §35 and §37 are explicit
that a depiction — and therefore a description written to guide one — must
never become canon on its own, and that promoting a visual detail into canon
would have to be a deliberate act by the reader.
So these rows are deliberately **outside** the M5 pipeline. They are not
events, they are not validated by `narrative/validate.py`, they are not in
the state document, and they are not snapshotted per position. Writing one
cannot change `narrative_state`, because nothing in `media/` imports the
code that may. That is the guarantee, and it is a structural one rather than
a rule somebody has to remember.
## Campaign-scoped, not per-position — which is the interesting decision
Every other derived record in this schema carries a `(branch_id, depth)`
coordinate, because it describes a *moment*: a memory summarises a stretch,
a summary covers a range, a snapshot records an outcome. A visual profile
describes none of those. It says what someone looks like, and a character
does not change appearance because the story forked.
Making it per-position would have been actively wrong twice over. It would
have meant a profile written on one branch was invisible on another, so a
reader who diverged would lose their cast's appearance — the opposite of the
continuity the profile exists for. And it would have put a descriptor
document into every per-position state snapshot, which M9 measured as
already 74% of a campaign bundle; the profiles would have been duplicated
once per turn to say something that never varies.
So the key is `(adventure_id, entity_key)` and there is exactly one profile
per entity per campaign. It is stable across Undo, Redo, Save Point restore
and divergence for the same reason it is simple: there is nothing there to
move.
## `entity_key` is the M5 key, and no second identity namespace
The key is the entity key the narrative state already uses — `"mara"`,
`"the_office"`, `"silver_key"` — not a new id, not a name, and not a media
identifier. `MEDIA-EXTENSION-CONTRACT.md` §7-9 describe character, location
and item profiles separately; this is one table for all three, because M5's
entity model is genre-neutral by design (`DATA-MODEL.md` §9) and a
character, a location, an item, a vehicle and a spaceship are all entities
with a `type`. Splitting them here would have reintroduced the genre shape
M5 spent a milestone removing.
There is no `kind` column for the same reason: the entity already has a
`type`, and storing it again would be a second source of truth for one fact.
## The columns, and why they are shaped this way
The contract's examples are fantasy-shaped — hair, eyes, build; architecture,
hearths, oil lamps — and the brief is explicit that they are examples rather
than a schema. A fixed column per fantasy attribute would not hold an
orbital station, a corporate office or a car.
So: `descriptors` is an open map of trait to value, `features` is a list of
distinctive visible things, and `style_notes` is free text about how it
should be rendered. `{"hair": "dark auburn"}` and
`{"hull": "pitted white composite"}` are the same shape, and neither needed
a migration to become possible.
"""
__tablename__ = "visual_profiles"
__table_args__ = (
# One profile per entity per campaign. The uniqueness is the model: a
# second profile for the same entity would be a second answer to "what
# does this look like", with nothing to decide between them.
UniqueConstraint("adventure_id", "entity_key", name="uq_visual_entity"),
)
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE"), index=True
)
#: The narrative-state entity key. Not a display name: two characters may
#: share a name, and M9's report recorded that the state model permits it.
entity_key: Mapped[str] = mapped_column(String(200))
#: Trait -> value. Open by construction; see the class docstring.
descriptors: Mapped[dict] = mapped_column(JSON, default=dict)
#: Distinctive visible things, as short phrases.
features: Mapped[list] = mapped_column(JSON, default=list)
#: How it should be rendered, rather than what it is.
style_notes: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow
)
adventure: Mapped[Adventure] = relationship(back_populates="visual_profiles")
class StoryCard(Base):
"""Owned by either a scenario or an adventure (exactly one set)."""
@@ -42,6 +42,7 @@ from . import ( # noqa: F401
memories,
actions,
knowledge,
visuals,
)
from ... import limits # noqa: F401 `adventures.limits` is patched by tests.
from .crud import SNIPPET_MAX, _snippet
+131
View File
@@ -0,0 +1,131 @@
"""M10: reading and writing how a campaign's entities look.
Four endpoints on the campaign, and one on the scene beneath it. They are the
only reader-facing surface M10 adds, and they are an API surface rather than a
browser one: M10 builds no gallery, no picker and no preview, because there is
nothing to generate and a screen for configuring depictions nobody can make
would be a feature pretending to be a seam.
## Why a scene-packet endpoint exists at all
`GET .../scene-packet` returns exactly what a future media coordinator would be
handed (`media/packet.py`). Nothing in v1 calls it, and it generates nothing.
It is here because it is the one part of M10 whose *contents* are a
correctness claim — that a provider is given a bounded view and not the
campaign, and that narrator-only material does not travel through it. A claim
like that should be inspectable by whoever is reviewing the boundary, not only
by a test that imports a private function. It is a read: it writes nothing,
emits no event, and cannot move the head.
## What these endpoints deliberately are not
They are not a state API. A visual profile is presentation metadata and writing
one changes no story fact (`models.VisualProfile`), so there is no event, no
proposal, no snapshot and no head movement anywhere below here. The separation
is structural — this module reaches `media.profiles`, and that module imports
nothing that can write authoritative state.
"""
from fastapi import Body, Depends, HTTPException
from sqlalchemy.orm import Session
from ... import models
from ...database import get_db
from ...media import packet as scene_packet
from ...media import profiles as visual_profiles
from .deps import current_adventure, router
@router.get("/{adventure_id}/visual-profiles")
def list_visual_profiles(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Every visual profile in the campaign, by entity key.
Campaign-scoped rather than scoped to the story being read, because that is
what a profile is: a character does not change appearance when the story
forks, so there is no position for this list to be relative to.
"""
return {
"profiles": [
{"entity_key": row.entity_key, **visual_profiles.as_dict(row)}
for row in visual_profiles.all_for(db, adventure)
],
}
@router.put("/{adventure_id}/visual-profiles/{entity_key}")
def set_visual_profile(
entity_key: str,
payload: dict = Body(...),
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Records how one entity looks. Replaces any existing profile.
A `PUT` rather than a `PATCH`, and the whole profile rather than a delta,
for the reason `profiles.set_profile` gives: merging would make a descriptor
impossible to remove.
The entity must exist in the campaign's state at the active head. A 400 for
a name nobody has is better than a row describing nobody, which would then
be invisible until a future depiction quietly ignored it.
"""
try:
row = visual_profiles.set_profile(
db, adventure, entity_key,
descriptors=payload.get("descriptors"),
features=payload.get("features"),
style_notes=payload.get("style_notes"),
)
except visual_profiles.ProfileError as exc:
raise HTTPException(400, str(exc)) from exc
db.commit()
db.refresh(row)
return {"entity_key": row.entity_key, **visual_profiles.as_dict(row)}
@router.get("/{adventure_id}/visual-profiles/{entity_key}")
def read_visual_profile(
entity_key: str,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
row = visual_profiles.get_profile(db, adventure, entity_key)
if row is None:
raise HTTPException(404, f"No visual profile for {entity_key!r}.")
return {"entity_key": row.entity_key, **visual_profiles.as_dict(row)}
@router.delete("/{adventure_id}/visual-profiles/{entity_key}", status_code=204)
def delete_visual_profile(
entity_key: str,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Removes a description. Never the entity, which lives in the state."""
if not visual_profiles.delete_profile(db, adventure, entity_key):
raise HTTPException(404, f"No visual profile for {entity_key!r}.")
db.commit()
@router.get("/{adventure_id}/scene-packet")
def read_scene_packet(
start: int | None = None,
end: int | None = None,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""What a future media provider would be given for the current scene.
`start` and `end` are depths on the active branch, and both are optional:
omitted, the packet describes the scene at the position the story last set
one. Passing a range is what a future video request would do — a scene is
not assumed to be one turn (`MEDIA-EXTENSION-CONTRACT.md` §30-31).
Generates nothing and contacts nothing. There is no provider to send it to.
"""
return scene_packet.build(db, adventure, start=start, end=end)
+136
View File
@@ -0,0 +1,136 @@
"""M10: a campaign with a scene worth depicting, deliberately not a fantasy one.
The M10 brief asks for at least one non-fantasy representation, and the reason
is a real risk rather than a preference: the media contract's own examples are
fantasy-shaped — hair and eyes, timber framing, oil lamps — and a schema written
while looking at them can acquire that shape without anyone deciding to give it
one. So the fixture is four people in an office, and the same code has to hold
it with no change.
Bill the protagonist
Alice a coworker, with a visual profile
Roger a coworker, with no profile at all
John a coworker who is not in the room
the office a location, with a visual profile
a badge an item Bill is carrying
the server room a second location, for divergence
The cast is the one from the post-M8 playtest finding, and that is deliberate
too — but only as *shape*. M10 does not investigate that finding, and nothing
here asserts anything about coreference; it is M11's, and §23 of the brief says
so. What the shape buys here is a scene with three present characters and one
absent, which is what makes "the packet describes who is in the room" a claim
with a wrong answer available.
Roger having no profile is load-bearing: it is how the tests tell "no profile"
from "an empty profile", which a future provider has to be able to distinguish.
"""
from __future__ import annotations
from fakes import ScriptedProvider, state_block
#: A narrator-only secret, used by the hidden-information tests. It is imported
#: as an M7 hidden knowledge source — the product's real mechanism for
#: narrator-only material — rather than as an invented marker, so the test
#: exercises the boundary that actually exists.
SECRET_SENTINEL = "ZARQUON-CONCEALED-OBSERVER-7731"
SECRET_MD = f"""# What nobody in the room knows
There is a concealed observer behind the north wall of the office, watching the
meeting through a gap in the panelling. Their code name is {SECRET_SENTINEL}.
Nobody present is aware of this.
"""
#: A source that is *not* hidden, so a test can show the packet excludes
#: imported knowledge as a class rather than only excluding secrets.
HANDBOOK_MD = """# Office handbook
The building was refurbished in the spring. The north wall panelling is new.
"""
def play(client, adv_id, text, events, prose="The meeting continues."):
ScriptedProvider.replies = [f"{prose}\n" + state_block(events)]
response = client.post(
f"/api/adventures/{adv_id}/actions", json={"type": "do", "text": text}
)
assert response.status_code == 200, response.text[:400]
return response
def entity(key, kind, name):
return {"type": "create_entity", "entity": key, "entity_type": kind,
"name": name}
def build(client, adv_id) -> dict:
"""Plays the office campaign and returns what a test needs to check it.
Leaves the campaign with a scene set at the active head, two visual
profiles, one character deliberately unprofiled, and one character
deliberately not present.
"""
play(client, adv_id, "arrive at the office", [
entity("bill", "character", "Bill"),
entity("alice", "character", "Alice"),
entity("roger", "character", "Roger"),
entity("john", "character", "John"),
entity("office", "location", "The office"),
entity("server_room", "location", "The server room"),
entity("badge", "item", "Security badge"),
])
play(client, adv_id, "start the meeting", [
{"type": "set_possession", "item": "badge", "owner": "bill"},
{"type": "set_scene",
"summary": "Bill, Alice and Roger meet around the table.",
"location": "office",
"present": ["bill", "alice", "roger"]},
])
profiles = {
"alice": {
"descriptors": {"build": "tall", "hair": "short black",
"clothing": "grey blazer"},
"features": ["tortoiseshell glasses"],
"style_notes": "photographic, natural light",
},
"office": {
"descriptors": {"architecture": "open-plan floor",
"lighting": "flat fluorescent"},
"features": ["whiteboard covered in diagrams"],
"style_notes": "",
},
}
for key, profile in profiles.items():
response = client.put(
f"/api/adventures/{adv_id}/visual-profiles/{key}", json=profile
)
assert response.status_code == 200, response.text[:300]
return {"profiles": profiles}
def upload_secret(client, adv_id) -> int:
"""Imports the narrator-only source the hidden-information tests use."""
return _upload(client, adv_id, "observer.md", SECRET_MD, "canon",
visibility="hidden")
def upload_handbook(client, adv_id) -> int:
return _upload(client, adv_id, "handbook.md", HANDBOOK_MD, "reference")
def _upload(client, adv_id, name, body, classification, **fields):
data = {"classification": classification}
data.update({k: str(v).lower() if isinstance(v, bool) else str(v)
for k, v in fields.items()})
response = client.post(
f"/api/adventures/{adv_id}/knowledge",
files={"file": (name, body.encode("utf-8"), "text/markdown")},
data=data,
)
assert response.status_code == 201, response.text[:400]
return response.json()["id"]
+342
View File
@@ -0,0 +1,342 @@
"""M10 §6 and §18: the media layer cannot write the story.
The architectural claim is one sentence — *media is derived presentation, story
state is authoritative, and there is no reverse path* — and this file is the
part of it that is checked by running things rather than by reading imports.
Every test here follows the same shape, which is the shape that makes it
evidence rather than assertion:
record the authoritative document, byte for byte
do the media-layer thing
record it again
require them to be identical
That catches a write nobody intended as well as one somebody did, and it does
not depend on knowing *how* a violation would have happened.
`test_m10_media_hooks.py` covers what the boundary carries; this covers what it
must never push back through.
python -m pytest tests/test_m10_authority.py -v
"""
import copy
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.media import packet as scene_packet
from app.media import profiles as visual_profiles
from app.media import providers
from app.routers import adventures
import m10_fixture
from fakes import ScriptedProvider
class StubDerived:
async def complete(self, system, prompt, **kwargs):
return "A memory."
async def embed(self, texts):
return [[1.0, 0.5, 0.25] for _ in texts]
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
embeddings._cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="m10auth@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(user_id=user.id, title="Authority")
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text="It begins.",
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
memorybank._vector_cache.clear()
embeddings._cache.clear()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def office(client):
return m10_fixture.build(client, client.adv_id)
def authoritative(adv_id) -> dict:
"""Everything the story counts as true, read straight from the database."""
with SessionLocal() as db:
adventure = db.get(models.Adventure, adv_id)
return {
"state": copy.deepcopy(adventure.narrative_state),
"head_branch": adventure.head_branch_id,
"head_depth": adventure.head_depth,
"events": db.query(models.StateEvent).filter(
models.StateEvent.adventure_id == adv_id).count(),
"proposals": db.query(models.StateProposal).filter(
models.StateProposal.adventure_id == adv_id).count(),
"actions": db.query(models.Action).filter(
models.Action.adventure_id == adv_id).count(),
}
# ------------------------------------------------------ writes that must not
def test_writing_a_visual_profile_changes_no_story_state(client, office):
before = authoritative(client.adv_id)
response = client.put(
f"/api/adventures/{client.adv_id}/visual-profiles/bill",
json={"descriptors": {"build": "heavyset", "clothing": "navy suit"},
"features": ["signet ring"], "style_notes": "photographic"},
)
assert response.status_code == 200, response.text[:300]
assert authoritative(client.adv_id) == before
def test_updating_a_visual_profile_creates_no_state_fact(client, office):
"""§6's example, made concrete.
A profile saying Alice wears a blue coat must not make it true that Alice
owns or wears a blue coat. Checked by looking for the words in the
authoritative document afterwards, not only by comparing counts.
"""
before = authoritative(client.adv_id)
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/alice",
json={"descriptors": {"clothing": "blue coat"}})
after = authoritative(client.adv_id)
assert after == before
assert "blue coat" not in repr(after["state"])
document = client.get(
f"/api/adventures/{client.adv_id}/state").json()["document"]
assert not any("blue coat" in repr(f) for f in document["facts"])
assert "blue coat" not in repr(document["entities"]["alice"])
def test_deleting_a_visual_profile_changes_no_story_state(client, office):
before = authoritative(client.adv_id)
assert client.delete(
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
).status_code == 204
assert authoritative(client.adv_id) == before
def test_building_a_scene_packet_changes_nothing(client, office):
"""A packet is a read. Built repeatedly, it must still be a read."""
before = authoritative(client.adv_id)
for _ in range(5):
assert client.get(
f"/api/adventures/{client.adv_id}/scene-packet"
).status_code == 200
assert authoritative(client.adv_id) == before
def test_a_scene_packet_does_not_move_the_head(client, office):
before = authoritative(client.adv_id)
client.get(f"/api/adventures/{client.adv_id}/scene-packet?start=0&end=4")
after = authoritative(client.adv_id)
assert after["head_branch"] == before["head_branch"]
assert after["head_depth"] == before["head_depth"]
def test_a_dummy_media_result_cannot_reach_the_story(client, office):
"""§18: adding a depiction, even a wrong one, changes nothing.
The result claims Alice is wearing a red coat and standing in a corridor.
None of that is true in the campaign, and after registering, generating and
holding the result, none of it has become true.
"""
import asyncio
before = authoritative(client.adv_id)
packet = client.get(
f"/api/adventures/{client.adv_id}/scene-packet").json()
class WrongProvider:
def capabilities(self):
return providers.ProviderCapabilities(
provider_id="wrong", kinds=(providers.IMAGE,))
async def generate(self, request):
return providers.MediaResult(
kind=providers.IMAGE, media_type="image/png",
data=b"\x89PNG\r\n\x1a\n",
provenance={"scene_id": request.scene["scene_id"]},
details={"depicts": "Alice in a red coat in a corridor"},
)
providers.register("wrong", WrongProvider())
try:
result = asyncio.run(WrongProvider().generate(
providers.MediaRequest(kind=providers.IMAGE, scene=packet)))
assert "red coat" in result.details["depicts"]
finally:
providers.unregister("wrong")
after = authoritative(client.adv_id)
assert after == before
assert "red coat" not in repr(after["state"])
assert "corridor" not in repr(after["state"])
def test_a_provider_failure_cannot_advance_the_head(client, office):
"""§18: a media failure is not a story event."""
import asyncio
before = authoritative(client.adv_id)
class FailingProvider:
def capabilities(self):
return providers.ProviderCapabilities(
provider_id="failing", kinds=(providers.IMAGE,))
async def generate(self, request):
raise providers.MediaProviderError("the local generator is not running")
providers.register("failing", FailingProvider())
try:
with pytest.raises(providers.MediaProviderError):
asyncio.run(FailingProvider().generate(providers.MediaRequest(
kind=providers.IMAGE,
scene=client.get(
f"/api/adventures/{client.adv_id}/scene-packet").json())))
finally:
providers.unregister("failing")
assert authoritative(client.adv_id) == before
def test_a_scene_derivation_failure_does_not_corrupt_an_accepted_turn(client, office):
"""§18: if building a packet raised, the story would be untouched.
The failure is induced in the packet builder itself, which is the only place
derivation happens, and the accepted turn either side is compared whole.
"""
before = authoritative(client.adv_id)
original = scene_packet.build
def explode(*args, **kwargs):
raise RuntimeError("scene derivation failed")
scene_packet.build = explode
try:
response = client.get(f"/api/adventures/{client.adv_id}/scene-packet")
assert response.status_code >= 500
except RuntimeError:
pass # the TestClient re-raises; either way the story must be intact
finally:
scene_packet.build = original
assert authoritative(client.adv_id) == before
# And the campaign still plays.
m10_fixture.play(client, client.adv_id, "carry on", [])
assert authoritative(client.adv_id)["actions"] == before["actions"] + 2
# ------------------------------------------------- rebuilding derived data
def test_deleting_every_visual_profile_leaves_the_campaign_intact(client, office):
"""§18's last clause: derived data can go without taking the story with it.
Profiles are the only thing M10 persists, and they are recoverable only from
a bundle or by being written again — so the promise here is narrower than
M9's rebuildable indexes, and the test states the narrow thing: removing
them costs the descriptions and nothing else.
"""
before = authoritative(client.adv_id)
with SessionLocal() as db:
db.query(models.VisualProfile).filter(
models.VisualProfile.adventure_id == client.adv_id
).delete(synchronize_session=False)
db.commit()
assert authoritative(client.adv_id) == before
assert client.get(
f"/api/adventures/{client.adv_id}/visual-profiles").json()["profiles"] == []
# The packet still builds; it simply describes nobody's appearance.
p = client.get(f"/api/adventures/{client.adv_id}/scene-packet").json()
assert [c["name"] for c in p["characters"]] == ["Bill", "Alice", "Roger"]
assert all(c["visual_profile"] is None for c in p["characters"])
def test_the_story_survives_a_profile_naming_a_vanished_entity(client, office):
"""A profile whose entity is gone is inert, not a corruption.
Reachable through an import: a bundle may carry a profile for an entity that
only exists on a branch the campaign has left.
"""
with SessionLocal() as db:
db.add(models.VisualProfile(
adventure_id=client.adv_id, entity_key="nobody_at_all",
descriptors={"hair": "green"}, features=[], style_notes=""))
db.commit()
before = authoritative(client.adv_id)
p = client.get(f"/api/adventures/{client.adv_id}/scene-packet").json()
assert "green" not in repr(p)
assert authoritative(client.adv_id) == before
m10_fixture.play(client, client.adv_id, "carry on", [])
# ---------------------------------------------- the separation, structurally
def test_the_media_package_imports_nothing_that_writes_state(client):
"""The guarantee behind every test above, checked as an import rule.
`narrative.apply` and `narrative.store` are the only modules that write the
authoritative document, and `media/` reaching either of them would make the
separation a convention rather than a fact. `narrative.model` and
`narrative.store.current` are reads and are used.
"""
import pathlib
seam = pathlib.Path(__file__).resolve().parent.parent / "app" / "media"
for path in seam.rglob("*.py"):
body = path.read_text()
assert "narrative.apply" not in body, path.name
assert "from ..narrative import apply" not in body, path.name
assert "set_current" not in body, path.name
assert "head.move_to" not in body, path.name
assert "tree.place_action" not in body, path.name
def test_no_state_event_type_was_added_for_media(client):
"""M10 adds no way for the media layer to speak in the story's vocabulary."""
from app.narrative import events
assert not any(
name.startswith("media") or "visual" in name or "asset" in name
for name in events.ALLOWED
)
+481
View File
@@ -0,0 +1,481 @@
"""M10 §14 and §15: the profiles travel, and an M9 database opens.
Two questions, and they are the ones a reader would ask if they knew what M10
had done to their machine:
* **§14 — does a campaign still move?** A visual profile is part of the campaign
the reader built, so it belongs in the bundle. It is also *new*, which is the
risk: an exporter that carries it and an importer that drops it both pass a
test that only checks the campaign still opens.
* **§15 — does the database I already have still work?** M10 adds one table and
nothing else. An existing campaign must survive opening under the new build
untouched, opening must not care how many times it happens, the schema an M9
file reaches must be the schema a fresh install has, and M9's backup must keep
working on the result.
The upgrade needs **no migration**: `create_all` builds a new table and the
indexes declared on its columns on every path. A `CREATE INDEX` migration was
written here first and `test_a_fresh_database_arrives_at_the_same_place` is what
found it wrong — it left an upgraded database holding an index a fresh install
did not have. That test is the one to keep pointed at any future schema change.
The bundle format stays `ai-dnd-adventure-v3`. M9's own test for a version bump
is whether omission creates ambiguity about what an older file *could* have
recorded, and it does not: a campaign with no visual profiles is the ordinary
case, so an absent key means "none" rather than "unknown". The tests below hold
that decision to its consequence — an M9-written v3 file must still import, and
the M10 exporter must still produce a file an M9 build would recognise.
python -m pytest tests/test_m10_bundle.py -v
"""
import copy
import os
import shutil
import sqlite3
import tempfile
from pathlib import Path
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app import auth, backup, limits, migrations, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
import m10_fixture
from fakes import ScriptedProvider
from test_process_restart import Server, _free_port
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="m10bundle@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model",
embedding_model=""))
adventure = models.Adventure(user_id=user.id, title="Portable office")
setup.add(adventure)
setup.flush()
setup.add(models.Action(adventure_id=adventure.id, type="start",
text="Bill badges in."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
Base.metadata.drop_all(bind=engine)
def export(client, adv_id=None) -> dict:
response = client.get(f"/api/adventures/{adv_id or client.adv_id}/export")
assert response.status_code == 200, response.text[:400]
return response.json()
def bring_back(client, payload) -> int:
response = client.post("/api/adventures/import", json=payload)
assert response.status_code == 201, response.text[:600]
return response.json()["id"]
def profiles_of(client, adv_id) -> dict:
body = client.get(f"/api/adventures/{adv_id}/visual-profiles").json()
return {p["entity_key"]: p for p in body["profiles"]}
@pytest.fixture()
def moved(client):
"""The office campaign, its bundle, and the copy the bundle produced."""
m10_fixture.build(client, client.adv_id)
payload = export(client)
return {"bundle": payload, "copy_id": bring_back(client, payload)}
# --------------------------------------------------------------- §14 the file
def test_the_format_version_is_unchanged(moved):
"""The decision, recorded as a test so a later bump is deliberate."""
assert moved["bundle"]["format"] == "ai-dnd-adventure-v3"
def test_the_bundle_carries_the_profiles_that_exist(moved):
exported = {p["entityKey"]: p for p in moved["bundle"]["visualProfiles"]}
assert set(exported) == {"alice", "office"}
assert exported["alice"]["descriptors"]["hair"] == "short black"
assert exported["alice"]["features"] == ["tortoiseshell glasses"]
assert exported["alice"]["styleNotes"] == "photographic, natural light"
def test_an_unprofiled_character_exports_no_empty_profile(moved):
"""Roger has no profile, and the file must say that by omission.
An exporter that wrote a blank row for every entity would lose the
distinction a provider needs: "nobody decided what Roger looks like" is not
"Roger looks like nothing".
"""
keys = [p["entityKey"] for p in moved["bundle"]["visualProfiles"]]
assert "roger" not in keys and "bill" not in keys
def test_the_copy_holds_the_same_profiles(client, moved):
original = profiles_of(client, client.adv_id)
copied = profiles_of(client, moved["copy_id"])
assert set(copied) == set(original)
for key in original:
assert copied[key]["descriptors"] == original[key]["descriptors"]
assert copied[key]["features"] == original[key]["features"]
assert copied[key]["style_notes"] == original[key]["style_notes"]
def test_the_copys_profiles_are_its_own_rows(client, moved):
"""Editing the copy must not reach back into the original."""
client.put(f"/api/adventures/{moved['copy_id']}/visual-profiles/alice",
json={"descriptors": {"hair": "bleached"}})
assert profiles_of(client, client.adv_id)["alice"][
"descriptors"]["hair"] == "short black"
def test_the_copys_scene_packet_is_populated_from_the_imported_profiles(
client, moved):
"""The point of carrying them: the copy can be depicted without redoing work."""
packet = client.get(
f"/api/adventures/{moved['copy_id']}/scene-packet").json()
by_name = {c["name"]: c for c in packet["characters"]}
assert by_name["Alice"]["visual_profile"]["descriptors"]["build"] == "tall"
assert by_name["Roger"]["visual_profile"] is None
assert packet["location"]["visual_profile"]["descriptors"][
"lighting"] == "flat fluorescent"
def test_an_m9_era_file_still_imports_and_simply_has_no_profiles(client, moved):
"""A v3 file written before M10 existed: the key is absent, not empty."""
older = copy.deepcopy(moved["bundle"])
del older["visualProfiles"]
copy_id = bring_back(client, older)
assert profiles_of(client, copy_id) == {}
# And the campaign itself arrived intact.
assert client.get(f"/api/adventures/{copy_id}/scene-packet").json()[
"characters"]
def test_a_malformed_profile_is_dropped_rather_than_refusing_the_campaign(
client, moved):
"""§14's proportionality rule, in the one place M10 could get it wrong.
A story that will not import because a description of somebody's coat is
malformed would be the wrong trade. The campaign arrives; the bad profile
does not; the good one does.
"""
damaged = copy.deepcopy(moved["bundle"])
damaged["visualProfiles"].append(
{"entity_key": "", "descriptors": "not an object"})
damaged["visualProfiles"].append({"descriptors": {"a": "b"}})
copy_id = bring_back(client, damaged)
assert set(profiles_of(client, copy_id)) == {"alice", "office"}
def test_a_profile_survives_a_second_round_trip_unchanged(client, moved):
"""Export, import, export again: the file is a fixed point."""
again = export(client, moved["copy_id"])
first = sorted(moved["bundle"]["visualProfiles"], key=lambda p: p["entityKey"])
second = sorted(again["visualProfiles"], key=lambda p: p["entityKey"])
assert [p["entityKey"] for p in first] == [p["entityKey"] for p in second]
for a, b in zip(first, second):
assert a["descriptors"] == b["descriptors"]
assert a["features"] == b["features"]
assert a["styleNotes"] == b["styleNotes"]
def test_a_neighbouring_campaigns_profiles_do_not_travel(client, moved):
"""Scoping: the exporter must filter by campaign, not by table."""
with SessionLocal() as db:
neighbour = models.Adventure(user_id=None, title="Someone else's")
db.add(neighbour)
db.flush()
db.add(models.VisualProfile(
adventure_id=neighbour.id, entity_key="intruder",
descriptors={"hair": "should not travel"}, features=[],
style_notes=""))
db.commit()
keys = [p["entityKey"] for p in export(client)["visualProfiles"]]
assert "intruder" not in keys
def test_the_planner_checks_the_profiles_before_a_row_is_written(moved):
"""M9's atomicity rule: everything is checked before anything is written.
`bundle.plan` is that checkpoint — it has no side effects and is what the
importer runs first — so a profile that would fail must fail there rather
than halfway through writing a campaign. There is no HTTP preview endpoint;
the planner is called directly for the same reason the importer calls it.
"""
from app import bundle as bundle_module
planned = bundle_module.plan(moved["bundle"], "ai-dnd-adventure-v3")
assert {p["entity_key"] for p in planned["visualProfiles"]} == {
"alice", "office"}
# ---------------------------------------------------------- §15 the migration
@pytest.fixture()
def m9_database():
"""A database as an M9 build left it, with a campaign already in it.
M10's only schema change is the `visual_profiles` table, so an M9-era file
is exactly this: the current schema without that table, stamped at 92 — the
version M9 ended on and, since M10 adds no migration, the version it still
ends on. The campaign rows are written before the upgrade, because the claim
under test is that they are still there afterwards.
"""
directory = tempfile.mkdtemp(prefix="m10-migrate-")
path = Path(directory) / "campaign.db"
older = create_engine(f"sqlite:///{path}")
Base.metadata.create_all(bind=older)
# Written through the ORM, so the campaign in the file is shaped the way the
# application writes one rather than the way a test guessed at.
with sessionmaker(bind=older)() as db:
adventure = models.Adventure(title="An M9 campaign")
db.add(adventure)
db.flush()
db.add(models.Action(adventure_id=adventure.id, type="start",
text="The story opened before M10."))
db.commit()
adv_id = adventure.id
with older.begin() as conn:
conn.execute(text("DROP TABLE visual_profiles"))
conn.execute(text("PRAGMA user_version = 92"))
older.dispose()
yield path, create_engine(f"sqlite:///{path}"), adv_id
def _indexes(engine_) -> set:
with engine_.begin() as conn:
return {row[0] for row in conn.execute(text(
"SELECT name FROM sqlite_master WHERE type = 'index'"))}
def _version(engine_) -> int:
with engine_.begin() as conn:
return conn.execute(text("PRAGMA user_version")).scalar()
def test_an_m9_database_gains_the_new_table_when_it_is_opened(m9_database):
path, older, adv_id = m9_database
assert _version(older) == 92
migrations.bootstrap(older)
assert _version(older) == migrations.LATEST_VERSION == 92
with older.begin() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM visual_profiles")).scalar() == 0
assert "ix_visual_profiles_adventure_id" in _indexes(older)
def test_the_campaign_that_was_already_there_is_untouched(m9_database):
path, older, adv_id = m9_database
migrations.bootstrap(older)
with older.begin() as conn:
assert conn.execute(text("SELECT title FROM adventures")).scalar() == (
"An M9 campaign")
assert conn.execute(text("SELECT text FROM actions")).scalar() == (
"The story opened before M10.")
assert conn.execute(text("PRAGMA foreign_key_check")).fetchall() == []
def test_opening_the_database_repeatedly_is_a_no_op(m9_database):
"""Three starts in a row. Nothing accumulates and nothing errors.
This is the idempotence §15 asks about. It is stated as "open it again"
rather than "run the migration again" because opening is what the
application does, and M10 has no migration of its own to rerun.
"""
path, older, adv_id = m9_database
migrations.bootstrap(older)
after_first = _indexes(older)
for _ in range(2):
migrations.bootstrap(older)
assert _version(older) == 92
assert _indexes(older) == after_first
with older.begin() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM adventures")).scalar() == 1
def test_a_fresh_database_arrives_at_the_same_place(m9_database):
"""An upgraded M9 file and a new install must not differ.
Two schemas that disagree is the failure this catches, and it is the one a
version stamp alone would hide.
"""
path, older, adv_id = m9_database
migrations.bootstrap(older)
fresh_path = path.with_name("fresh.db")
fresh = create_engine(f"sqlite:///{fresh_path}")
migrations.bootstrap(fresh)
assert _version(fresh) == _version(older)
def shape(e):
with e.begin() as conn:
return conn.execute(text(
"SELECT sql FROM sqlite_master WHERE name = 'visual_profiles'"
)).scalar()
assert shape(fresh) == shape(older)
# Including the indexes. This comparison is what caught the redundant
# `CREATE INDEX` migration M10 first shipped: the upgraded file had an index
# the fresh one did not, which is a difference no test of either database on
# its own would have shown.
assert _indexes(fresh) == _indexes(older)
fresh.dispose()
def test_a_backup_of_the_upgraded_database_still_works(m9_database):
"""M9's backup keeps its guarantees on a file M10 added a table to."""
path, older, adv_id = m9_database
migrations.bootstrap(older)
older.dispose()
result = backup.create(path)
try:
assert result.integrity == "ok"
assert result.pages > 0
with sqlite3.connect(f"file:{result.path}?mode=ro", uri=True) as copy_db:
assert copy_db.execute("PRAGMA quick_check").fetchone()[0] == "ok"
assert copy_db.execute("PRAGMA foreign_key_check").fetchall() == []
# It opens independently: the new table is in it, and so is the
# campaign that predates the migration.
assert copy_db.execute(
"SELECT COUNT(*) FROM visual_profiles").fetchone()[0] == 0
assert copy_db.execute(
"SELECT title FROM adventures").fetchone()[0] == "An M9 campaign"
assert copy_db.execute("PRAGMA user_version").fetchone()[0] == 92
finally:
result.path.unlink(missing_ok=True)
def test_a_backup_carries_the_profiles_written_after_the_upgrade(m9_database):
path, older, adv_id = m9_database
migrations.bootstrap(older)
with older.begin() as conn:
conn.execute(text(
"INSERT INTO visual_profiles "
"(adventure_id, entity_key, descriptors, features, style_notes, "
" created_at, updated_at) "
"VALUES (:adv, 'bill', '{\"build\": \"heavyset\"}', '[]', '', "
" datetime('now'), datetime('now'))"), {"adv": adv_id})
older.dispose()
result = backup.create(path)
try:
with sqlite3.connect(f"file:{result.path}?mode=ro", uri=True) as copy_db:
row = copy_db.execute(
"SELECT entity_key, descriptors FROM visual_profiles").fetchone()
assert row[0] == "bill" and "heavyset" in row[1]
finally:
result.path.unlink(missing_ok=True)
# ------------------------------------- §14 the move to a machine that never saw it
@pytest.fixture()
def machines():
"""Two directories, each with its own database, and a server on each.
The same shape as `test_m9_clean_import.py`, for the same reason: a shared
id space, a warm cache or a session still holding the original would let an
in-process import pass while a real move failed. M9's version of this test
predates visual profiles and carries none, so this is the profile-carrying
half of the same claim rather than a duplicate of it.
"""
root = tempfile.mkdtemp(prefix="m10-clean-")
started: list[Server] = []
def start(name: str) -> Server:
directory = os.path.join(root, name)
os.makedirs(directory, exist_ok=True)
server = Server(os.path.join(directory, "campaign.db"), _free_port())
started.append(server)
server.wait_until_ready()
return server
try:
yield start
finally:
for server in started:
server.stop()
shutil.rmtree(root, ignore_errors=True)
def test_profiles_reach_a_clean_data_directory_on_another_machine(machines):
"""§14's Definition-of-Done clause, run across two real processes.
Machine A plays a campaign, profiles two entities and exports. Machine B is
a database file that has never existed before, in a different directory, in
a different process — migrations run there from nothing. Nothing crosses but
the bundle.
"""
a = machines("machine-a")
campaign = a.call("POST", "/adventures",
{"title": "Moving day", "opening": "The office is quiet."},
expect=201)
adv = campaign["id"]
a.call("POST", f"/adventures/{adv}/state/corrections", {
"events": [
{"type": "create_entity", "entity": "alice",
"entity_type": "character", "name": "Alice"},
{"type": "create_entity", "entity": "roger",
"entity_type": "character", "name": "Roger"},
{"type": "create_entity", "entity": "office",
"entity_type": "location", "name": "The office"},
{"type": "set_scene", "summary": "Alice and Roger wait in the office.",
"location": "office", "present": ["alice", "roger"]},
],
"note": "setting the scene",
}, expect=201)
a.call("PUT", f"/adventures/{adv}/visual-profiles/alice",
{"descriptors": {"build": "tall", "hair": "short black"},
"features": ["tortoiseshell glasses"],
"style_notes": "photographic, natural light"}, expect=200)
a.call("PUT", f"/adventures/{adv}/visual-profiles/office",
{"descriptors": {"lighting": "flat fluorescent"}}, expect=200)
payload = a.call("GET", f"/adventures/{adv}/export", expect=200)
source_packet = a.call("GET", f"/adventures/{adv}/scene-packet", expect=200)
a.stop()
assert not a.is_listening()
b = machines("machine-b")
moved = b.call("POST", "/adventures/import", payload, expect=201)["id"]
profiles = {p["entity_key"]: p for p in b.call(
"GET", f"/adventures/{moved}/visual-profiles", expect=200)["profiles"]}
assert set(profiles) == {"alice", "office"}
assert profiles["alice"]["features"] == ["tortoiseshell glasses"]
assert profiles["alice"]["style_notes"] == "photographic, natural light"
# The packet the copy builds describes the same scene, with the same
# profiles attached and Roger still deliberately unprofiled. Only the
# campaign id differs, which is what a new machine's id space means.
moved_packet = b.call("GET", f"/adventures/{moved}/scene-packet", expect=200)
assert moved_packet["action_summary"] == source_packet["action_summary"]
by_name = {c["name"]: c for c in moved_packet["characters"]}
assert by_name["Alice"]["visual_profile"]["descriptors"]["hair"] == "short black"
assert by_name["Roger"]["visual_profile"] is None
assert moved_packet["location"]["visual_profile"]["descriptors"][
"lighting"] == "flat fluorescent"
+452
View File
@@ -0,0 +1,452 @@
"""M10 §4 and §17: scene data obeys the history rules, because it *is* story data.
The claim this file makes is unusual, and worth stating plainly before the
tests: **M10 wrote no lineage code.** There is no media head, no `active` flag,
no scene branch table and no separate restore path. The scene lives in the
authoritative narrative state document, which M3 gave a head, M4 gave Save
Points, M5 gave per-position snapshots and M9 gave portability — so it inherits
every one of those rules by being the same data rather than by copying them.
That makes these tests a check on an inheritance rather than on an
implementation, and they are written to fail loudly if the inheritance were ever
broken by a future scene store appearing beside the state document. The M10
brief's §4 sequence is exercised literally, including the restart, and the
Mara-in-the-cellar example it names is the first test.
python -m pytest tests/test_m10_lineage.py -v
"""
import json
import os
import shutil
import sqlite3
import tempfile
import urllib.request
from pathlib import Path
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.media import packet as scene_packet
from app.routers import adventures
import m10_fixture
from fakes import ScriptedProvider
from test_process_restart import Server, _free_port
class StubDerived:
async def complete(self, system, prompt, **kwargs):
return "A memory."
async def embed(self, texts):
return [[1.0, 0.5, 0.25] for _ in texts]
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
embeddings._cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="m10lin@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(user_id=user.id, title="Lineage")
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text="It begins.",
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
memorybank._vector_cache.clear()
embeddings._cache.clear()
Base.metadata.drop_all(bind=engine)
def scene_of(client, adv_id=None):
return client.get(
f"/api/adventures/{adv_id or client.adv_id}/state"
).json()["document"].get("scene") or {}
def packet_of(client, adv_id=None):
r = client.get(f"/api/adventures/{adv_id or client.adv_id}/scene-packet")
assert r.status_code == 200, r.text[:300]
return r.json()
def retained_scenes(adv_id) -> list[tuple]:
"""Every scene the tree still holds, as (branch, depth, summary).
Read from the per-position snapshots, which is where a retained scene lives
— the point being that a scene the story left is still on disk, attached to
the position that established it.
"""
from sqlalchemy.orm import undefer
with SessionLocal() as db:
rows = (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.options(undefer(models.Action.narrative_state_after))
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
.all()
)
out = []
for row in rows:
state = row.narrative_state_after or {}
summary = (state.get("scene") or {}).get("summary")
if summary:
out.append((row.branch_id, row.depth, summary))
return out
# --------------------------------------------- the brief's own §4 example
def test_a_scene_from_an_abandoned_line_does_not_become_current(client):
"""§4, literally: Mara in the cellar, then Mara upstairs.
Path A's scene must remain stored, must not be current on Path B, and
Path B's scene must be Path B's.
"""
m10_fixture.play(client, client.adv_id, "set up", [
m10_fixture.entity("mara", "character", "Mara"),
m10_fixture.entity("cellar", "location", "The cellar"),
m10_fixture.entity("upstairs", "location", "Upstairs"),
])
m10_fixture.play(client, client.adv_id, "go down", [
{"type": "set_scene", "summary": "Mara enters the cellar.",
"location": "cellar", "present": ["mara"]},
])
assert scene_of(client)["summary"] == "Mara enters the cellar."
path_a = packet_of(client)["scene_id"]
assert client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200
m10_fixture.play(client, client.adv_id, "stay put", [
{"type": "set_scene", "summary": "Mara remains upstairs.",
"location": "upstairs", "present": ["mara"]},
])
current = scene_of(client)
assert current["summary"] == "Mara remains upstairs."
assert current["location"] == "upstairs"
assert packet_of(client)["location"]["name"] == "Upstairs"
assert packet_of(client)["scene_id"] != path_a
# Path A's scene is still on disk, on the branch it belongs to.
kept = retained_scenes(client.adv_id)
assert ("Mara enters the cellar." in [s for _, _, s in kept]), kept
assert ("Mara remains upstairs." in [s for _, _, s in kept]), kept
branches = {s: b for b, _, s in kept}
assert branches["Mara enters the cellar."] != branches["Mara remains upstairs."]
def test_divergence_deletes_no_scene(client):
"""§4: diverging retains the old line rather than replacing it."""
m10_fixture.play(client, client.adv_id, "set up", [
m10_fixture.entity("mara", "character", "Mara"),
m10_fixture.entity("cellar", "location", "The cellar"),
])
m10_fixture.play(client, client.adv_id, "down", [
{"type": "set_scene", "summary": "Scene A.", "location": "cellar",
"present": ["mara"]}])
before = len(retained_scenes(client.adv_id))
client.post(f"/api/adventures/{client.adv_id}/undo")
m10_fixture.play(client, client.adv_id, "elsewhere", [
{"type": "set_scene", "summary": "Scene C.", "location": "cellar",
"present": ["mara"]}])
after = retained_scenes(client.adv_id)
assert len(after) == before + 1
assert "Scene A." in [s for _, _, s in after]
# ------------------------------------------------- the brief's §17 sequence
def test_the_full_scene_lineage_sequence(client):
"""§17, step by step, in one test so the order is the thing under test.
Scene A, Save Point, Scene B, Undo, Redo, restore, diverge to Scene C — and
at every step the active scene must be the one the head is on, while the
scenes the story left must still be on disk.
"""
adv = client.adv_id
m10_fixture.play(client, adv, "set up", [
m10_fixture.entity("mara", "character", "Mara"),
m10_fixture.entity("hall", "location", "The hall"),
])
client.put(f"/api/adventures/{adv}/visual-profiles/mara",
json={"descriptors": {"build": "sturdy"}})
# 1-2. Scene A, persisted.
m10_fixture.play(client, adv, "scene a", [
{"type": "set_scene", "summary": "Scene A.", "location": "hall",
"present": ["mara"]}])
assert scene_of(client)["summary"] == "Scene A."
# 3. Save Point at Scene A.
point = client.post(f"/api/adventures/{adv}/checkpoints",
json={"name": "At scene A", "note": ""})
assert point.status_code == 201, point.text[:300]
point_id = point.json()["id"]
# 4. Advance to Scene B.
m10_fixture.play(client, adv, "scene b", [
{"type": "set_scene", "summary": "Scene B.", "location": "hall",
"present": ["mara"]}])
assert scene_of(client)["summary"] == "Scene B."
# 5. Undo -> back at Scene A.
assert client.post(f"/api/adventures/{adv}/undo").status_code == 200
assert scene_of(client)["summary"] == "Scene A."
# 6. Redo -> Scene B again.
assert client.post(f"/api/adventures/{adv}/redo").status_code == 200
assert scene_of(client)["summary"] == "Scene B."
# 7. Restore the Save Point -> Scene A, and Scene B is still retained.
restored = client.post(f"/api/adventures/{adv}/checkpoints/{point_id}/restore")
assert restored.status_code == 200, restored.text[:300]
assert scene_of(client)["summary"] == "Scene A."
assert "Scene B." in [s for _, _, s in retained_scenes(adv)]
# 8. Diverge to Scene C.
m10_fixture.play(client, adv, "scene c", [
{"type": "set_scene", "summary": "Scene C.", "location": "hall",
"present": ["mara"]}])
assert scene_of(client)["summary"] == "Scene C."
# Scene B is retained and is NOT current on Scene C's line.
kept = [s for _, _, s in retained_scenes(adv)]
assert "Scene B." in kept and "Scene A." in kept and "Scene C." in kept
assert scene_of(client)["summary"] == "Scene C."
# 9-11. Restart, then inspect again. Nothing about eligibility moved.
with SessionLocal() as fresh:
adventure = fresh.get(models.Adventure, adv)
assert adventure.narrative_state["scene"]["summary"] == "Scene C."
# The profile is stable across every one of those movements.
profile = client.get(f"/api/adventures/{adv}/visual-profiles/mara").json()
assert profile["descriptors"] == {"build": "sturdy"}
def test_a_visual_profile_is_stable_across_divergence(client):
"""§17: a character does not change appearance because the story forked.
This is the one place M10's storage choice is directly observable: profiles
are campaign-scoped, so the same profile is visible from both lines.
"""
m10_fixture.play(client, client.adv_id, "set up", [
m10_fixture.entity("mara", "character", "Mara"),
m10_fixture.entity("hall", "location", "The hall"),
])
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/mara",
json={"descriptors": {"hair": "dark auburn"}})
m10_fixture.play(client, client.adv_id, "a", [
{"type": "set_scene", "summary": "A.", "location": "hall",
"present": ["mara"]}])
on_a = packet_of(client)["characters"][0]["visual_profile"]
client.post(f"/api/adventures/{client.adv_id}/undo")
m10_fixture.play(client, client.adv_id, "b", [
{"type": "set_scene", "summary": "B.", "location": "hall",
"present": ["mara"]}])
on_b = packet_of(client)["characters"][0]["visual_profile"]
assert on_a == on_b == {"descriptors": {"hair": "dark auburn"},
"features": [], "style_notes": ""}
def test_a_profile_survives_redo_and_a_save_point_restore(client):
"""The other two history operations, for the profile rather than the scene.
Divergence is covered above and is the interesting case; Redo and a Save
Point restore are covered here because K02 claims stability across all of
them, and a claim in a report should have a test under it rather than an
argument. Both move the head, and a profile that moved with it would be the
per-position storage M10 deliberately did not build.
"""
m10_fixture.play(client, client.adv_id, "set up", [
m10_fixture.entity("mara", "character", "Mara"),
m10_fixture.entity("hall", "location", "The hall"),
])
profile = {"descriptors": {"hair": "dark auburn"}, "features": ["a scar"],
"style_notes": "candlelight"}
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/mara",
json=profile)
point = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
json={"name": "Before the hall", "note": ""})
assert point.status_code == 201, point.text[:300]
m10_fixture.play(client, client.adv_id, "into the hall", [
{"type": "set_scene", "summary": "Mara stands in the hall.",
"location": "hall", "present": ["mara"]}])
expected = {"descriptors": {"hair": "dark auburn"}, "features": ["a scar"],
"style_notes": "candlelight"}
assert packet_of(client)["characters"][0]["visual_profile"] == expected
assert client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200
assert client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200
assert packet_of(client)["characters"][0]["visual_profile"] == expected
restored = client.post(
f"/api/adventures/{client.adv_id}/checkpoints/{point.json()['id']}/restore")
assert restored.status_code == 200, restored.text[:300]
# The scene is gone — it was set after the Save Point — and the profile is
# not, which is exactly the difference between story state and presentation
# metadata.
assert packet_of(client)["characters"] == []
assert client.get(
f"/api/adventures/{client.adv_id}/visual-profiles/mara"
).json()["descriptors"] == {"hair": "dark auburn"}
def test_nothing_relies_on_a_mutable_active_flag(client):
"""§4's last clause, checked structurally rather than by behaviour.
The scene follows the head because it *is* the state at the head. If a
future change introduced a scene table with its own `active` column, this
would be the test that noticed.
"""
assert not hasattr(models, "Scene")
columns = {c.name for c in models.VisualProfile.__table__.columns}
assert "active" not in columns
assert "branch_id" not in columns
assert "depth" not in columns
# ------------------------------------------------- a genuine process restart
@pytest.fixture()
def spawned():
"""A real server process against a real database file, twice.
`test_process_restart.py` owns the harness; M10 reuses it because "survives
a restart" is a claim about bytes on disk, and a same-process fixture cannot
tell durable state from a live object.
"""
directory = tempfile.mkdtemp(prefix="m10-restart-")
db_path = os.path.join(directory, "campaign.db")
started: list[Server] = []
def start() -> Server:
server = Server(db_path, _free_port())
started.append(server)
server.wait_until_ready()
return server
try:
yield start, db_path
finally:
for server in started:
server.stop()
shutil.rmtree(directory, ignore_errors=True)
def test_scene_and_profile_survive_a_genuine_process_restart(spawned):
"""K01/K02/K03's durability clause, across a real PID boundary.
The spawned server narrates with a deterministic provider that emits no
state events, so the scene and the entities are established through the
ordinary correction endpoint — which is a real, validated write path, not a
fixture reaching into the ORM.
"""
start, db_path = spawned
first = start()
campaign = first.call("POST", "/adventures", {
"title": "Restarted", "opening": "The office is quiet.",
}, expect=201)
adv = campaign["id"]
first.call("POST", f"/adventures/{adv}/state/corrections", {
"events": [
{"type": "create_entity", "entity": "alice",
"entity_type": "character", "name": "Alice"},
{"type": "create_entity", "entity": "office",
"entity_type": "location", "name": "The office"},
{"type": "set_scene", "summary": "Alice waits in the office.",
"location": "office", "present": ["alice"]},
],
"note": "setting the scene",
}, expect=201)
first.call("PUT", f"/adventures/{adv}/visual-profiles/alice",
{"descriptors": {"hair": "short black"},
"features": ["tortoiseshell glasses"]}, expect=200)
before_scene = first.call("GET", f"/adventures/{adv}/state",
expect=200)["document"]["scene"]
before_packet = first.call("GET", f"/adventures/{adv}/scene-packet", expect=200)
first.stop()
assert not first.is_listening()
second = start()
after_scene = second.call("GET", f"/adventures/{adv}/state",
expect=200)["document"]["scene"]
after_packet = second.call("GET", f"/adventures/{adv}/scene-packet", expect=200)
after_profile = second.call(
"GET", f"/adventures/{adv}/visual-profiles/alice", expect=200)
assert after_scene == before_scene
assert after_scene["summary"] == "Alice waits in the office."
assert after_packet == before_packet
assert after_profile["descriptors"] == {"hair": "short black"}
assert after_packet["characters"][0]["visual_profile"]["features"] == [
"tortoiseshell glasses"
]
def test_the_restarted_database_holds_the_profile_row(spawned):
"""Read out of the file itself, so "persisted" is not taken on trust."""
start, db_path = spawned
server = start()
campaign = server.call("POST", "/adventures",
{"title": "Rows", "opening": "Start."}, expect=201)
adv = campaign["id"]
server.call("POST", f"/adventures/{adv}/state/corrections", {
"events": [{"type": "create_entity", "entity": "ship",
"entity_type": "vehicle", "name": "The Persephone"}],
"note": "",
}, expect=201)
server.call("PUT", f"/adventures/{adv}/visual-profiles/ship",
{"descriptors": {"hull": "pitted white composite"}}, expect=200)
server.stop()
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
row = connection.execute(
"SELECT entity_key, descriptors FROM visual_profiles "
"WHERE adventure_id = ?", (adv,)
).fetchone()
finally:
connection.close()
assert row is not None
assert row[0] == "ship"
assert json.loads(row[1]) == {"hull": "pitted white composite"}
+568
View File
@@ -0,0 +1,568 @@
"""M10: the media seam — K01-K04, the packet, the profiles, the contracts.
Lineage behaviour has its own file (`test_m10_lineage.py`), as does the
authority separation (`test_m10_authority.py`) and the no-media claim
(`test_m10_no_media.py`), because those three are the claims a reviewer will
want to find whole rather than scattered.
python -m pytest tests/test_m10_media_hooks.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.media import packet as scene_packet
from app.media import profiles as visual_profiles
from app.media import providers
from app.routers import adventures
import m10_fixture
from fakes import ScriptedProvider
class StubDerived:
async def complete(self, system, prompt, **kwargs):
return "A memory."
async def embed(self, texts):
return [[1.0, 0.5, 0.25] for _ in texts]
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
embeddings._cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="m10@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(user_id=user.id, title="The Office")
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start",
text="Bill badges in on a Tuesday morning.",
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
memorybank._vector_cache.clear()
embeddings._cache.clear()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def office(client):
return m10_fixture.build(client, client.adv_id)
def packet_of(client, adv_id=None, **params):
response = client.get(
f"/api/adventures/{adv_id or client.adv_id}/scene-packet", params=params
)
assert response.status_code == 200, response.text[:400]
return response.json()
def state_of(client, adv_id=None):
return client.get(
f"/api/adventures/{adv_id or client.adv_id}/state"
).json()["document"]
# ------------------------------------------------------------------- K01
def test_k01_a_structured_scene_is_persisted_for_a_multi_character_scene(
client, office
):
"""K01. A scene with several characters and a clear location, **persisted**.
The acceptance text forbids satisfying this with an ephemeral dictionary
built inside a test, so the assertion is made against what a *second*
session reads out of the database — not against a value this test computed.
"""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
stored = adventure.narrative_state["scene"]
assert stored["summary"] == "Bill, Alice and Roger meet around the table."
assert stored["location"] == "office"
assert sorted(stored["present"]) == ["alice", "bill", "roger"]
# The coordinate is what makes it a scene *snapshot* rather than a note: it
# says which accepted position this describes.
assert stored["at"]["branch_id"] is not None
assert isinstance(stored["at"]["depth"], int)
def test_k01_the_persisted_scene_is_sufficient_to_depict(client, office):
"""Sufficiency, checked as "could something draw this?" rather than "is it non-empty?"."""
p = packet_of(client)
assert p["location"]["name"] == "The office"
assert [c["name"] for c in p["characters"]] == ["Bill", "Alice", "Roger"]
assert p["action_summary"] == "Bill, Alice and Roger meet around the table."
assert p["objects"] and p["objects"][0]["name"] == "Security badge"
assert p["scene_id"]
def test_the_scene_snapshot_is_per_position_and_survives_a_restart(client, office):
"""Persisted in the ordinary sense: a new session reads the same thing.
A genuine process restart is exercised in `test_m10_lineage.py`; this is the
cheaper claim that the value is on disk rather than in a live object.
"""
with SessionLocal() as first:
before = first.get(models.Adventure, client.adv_id).narrative_state["scene"]
with SessionLocal() as second:
after = second.get(models.Adventure, client.adv_id).narrative_state["scene"]
assert before == after
# ------------------------------------------------------------------- K02/K03
def test_k02_a_character_keeps_stable_visual_descriptors(client, office):
row = client.get(
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
).json()
assert row["descriptors"]["hair"] == "short black"
assert row["features"] == ["tortoiseshell glasses"]
assert row["style_notes"] == "photographic, natural light"
def test_k03_a_location_keeps_stable_visual_descriptors(client, office):
row = client.get(
f"/api/adventures/{client.adv_id}/visual-profiles/office"
).json()
assert row["descriptors"]["architecture"] == "open-plan floor"
assert row["features"] == ["whiteboard covered in diagrams"]
def test_profiles_survive_more_turns(client, office):
"""K02/K03 across turns: playing on does not disturb a profile."""
for i in range(3):
m10_fixture.play(client, client.adv_id, f"talk {i}", [])
row = client.get(
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
).json()
assert row["descriptors"]["hair"] == "short black"
def test_an_item_may_have_a_profile_too(client, office):
"""§5's optional third kind, and proof the one table holds all three.
There is no `kind` column: a character, a location and an item are all
entities in the M5 model, and the profile attaches to the entity key.
"""
response = client.put(
f"/api/adventures/{client.adv_id}/visual-profiles/badge",
json={"descriptors": {"material": "white plastic"},
"features": ["photo in the corner"]},
)
assert response.status_code == 200, response.text[:300]
assert packet_of(client)["objects"][0]["visual_profile"]["descriptors"] == {
"material": "white plastic"
}
def test_no_profile_is_distinguishable_from_an_empty_one(client, office):
"""A future provider must be able to tell "unstated" from "stated as nothing"."""
p = packet_of(client)
by_name = {c["name"]: c for c in p["characters"]}
assert by_name["Roger"]["visual_profile"] is None
assert by_name["Alice"]["visual_profile"] is not None
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/roger", json={})
again = {c["name"]: c for c in packet_of(client)["characters"]}
assert again["Roger"]["visual_profile"] == {
"descriptors": {}, "features": [], "style_notes": ""
}
def test_a_profile_must_name_an_entity_the_campaign_has(client, office):
"""A typo is an error, not a row describing nobody."""
response = client.put(
f"/api/adventures/{client.adv_id}/visual-profiles/alicce",
json={"descriptors": {"hair": "short black"}},
)
assert response.status_code == 400
assert "no entity called" in response.json()["detail"]
def test_a_profile_replaces_rather_than_merges(client, office):
"""So a descriptor can be removed, which a merge would make impossible."""
client.put(f"/api/adventures/{client.adv_id}/visual-profiles/alice",
json={"descriptors": {"hair": "short black"}})
row = client.get(
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
).json()
assert row["descriptors"] == {"hair": "short black"}
assert row["features"] == []
def test_deleting_a_profile_leaves_the_entity_alone(client, office):
"""A profile is a description. Removing it removes a description."""
assert client.delete(
f"/api/adventures/{client.adv_id}/visual-profiles/alice"
).status_code == 204
assert "alice" in state_of(client)["entities"]
assert {c["name"] for c in packet_of(client)["characters"]} == {
"Bill", "Alice", "Roger"
}
@pytest.mark.parametrize("bad", [
{"descriptors": {"hair": ["short", "black"]}},
{"descriptors": "short black hair"},
{"features": "glasses"},
{"style_notes": {"note": "photographic"}},
{"descriptors": {"hair": "x" * 5_000}},
])
def test_a_malformed_profile_is_refused(client, office, bad):
response = client.put(
f"/api/adventures/{client.adv_id}/visual-profiles/alice", json=bad
)
assert response.status_code == 400, response.text[:200]
# --------------------------------------------------------- scene identity
def test_scene_identity_resolves_back_to_a_position(client, office):
"""§3. A future asset holding this string can find the accepted scene again."""
p = packet_of(client)
resolved = scene_packet.parse_scene_id(p["scene_id"])
assert resolved["adventure_id"] == client.adv_id
assert resolved["branch_id"] == p["turn_range"]["branch_id"]
assert resolved["start"] == p["turn_range"]["start"]
assert resolved["end"] == p["turn_range"]["end"]
def test_a_scene_may_span_several_turns(client, office):
"""§3: one turn is not assumed to be one scene, which a video needs."""
p = packet_of(client, start=0, end=4)
assert p["turn_range"]["start"] == 0
assert p["turn_range"]["end"] == 4
assert p["scene_id"].endswith(":0-4")
assert scene_packet.parse_scene_id(p["scene_id"])["end"] == 4
def test_a_reversed_range_is_read_in_order(client, office):
assert packet_of(client, start=4, end=0)["turn_range"] == \
packet_of(client, start=0, end=4)["turn_range"]
def test_several_assets_may_name_one_scene(client, office):
"""§3: nothing allocates or records a scene, so nothing bounds how many
future assets refer to it. Two builds of the same scene agree exactly."""
assert packet_of(client)["scene_id"] == packet_of(client)["scene_id"]
# ------------------------------------------------------- the packet's bounds
def test_the_packet_does_not_carry_the_transcript(client, office):
"""§12. A provider gets the scene, not the campaign."""
for i in range(4):
m10_fixture.play(client, client.adv_id, f"say something memorable {i}", [],
prose=f"Roger tells a long story about the printer {i}.")
blob = repr(packet_of(client))
assert "printer" not in blob
assert "Bill badges in on a Tuesday morning" not in blob
def test_the_packet_carries_no_imported_knowledge_at_all(client, office):
"""Not just secrets: imported material as a class stays out.
A positive control comes with it — the source really was imported and really
does reach the narrator — so this cannot pass because the upload failed.
"""
m10_fixture.upload_handbook(client, client.adv_id)
m10_fixture.play(client, client.adv_id, "ask about the north wall panelling", [])
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
assert any("handbook" in r["filename"] for r in report["knowledge"]["used"]), (
"the control failed: the narrator never saw the handbook, so this "
"proves nothing about the packet"
)
assert "refurbished" not in repr(packet_of(client))
def test_the_packet_is_bounded_when_the_state_is_large(client, office):
"""A scene with many entities does not produce an unbounded packet.
Thirty extras rather than more, because `set_scene`'s `present` is itself
capped at `validate.MAX_LABELS` (40) — asking for more gets the *event*
refused and leaves the previous scene standing, which would make this test
pass by measuring the wrong scene. The precondition is asserted first for
exactly that reason.
"""
extras = [f"extra_{i}" for i in range(30)]
m10_fixture.play(client, client.adv_id, "the whole floor arrives",
[m10_fixture.entity(k, "character", f"Extra {k[-2:]}")
for k in extras])
m10_fixture.play(client, client.adv_id, "everyone crowds in", [
{"type": "set_scene", "summary": "The whole floor crowds in.",
"location": "office",
"present": ["bill", "alice", "roger"] + extras},
])
present = state_of(client)["scene"]["present"]
assert len(present) == 33, (
f"the scene was not set as this test intends ({len(present)} present), "
f"so the bound below would be measuring the wrong scene"
)
p = packet_of(client)
assert len(p["characters"]) == scene_packet.MAX_CHARACTERS
assert len(p["continuity_constraints"]) <= scene_packet.MAX_CONSTRAINTS
# ------------------------------------------------------- provider contracts
def test_no_provider_is_registered(client):
"""v1 ships none, and nothing registers one at import."""
assert providers.registered() == {}
for kind in providers.MEDIA_KINDS:
assert providers.for_kind(kind) == []
def test_a_provider_can_be_added_without_touching_story_code(client, office):
"""M10's Definition of Done, as an executable claim.
A provider is registered, asked to depict the current scene, and returns —
and nothing in the story engine was modified, imported or subclassed to make
that work. The adapter satisfies a `Protocol`, so it did not even have to
import the base class.
"""
seen = {}
class FakeImageProvider:
def capabilities(self):
return providers.ProviderCapabilities(
provider_id="fake-local", kinds=(providers.IMAGE,),
)
async def generate(self, request):
seen["scene_id"] = request.scene["scene_id"]
return providers.MediaResult(
kind=providers.IMAGE, media_type="image/png",
data=b"\x89PNG\r\n\x1a\n",
provenance={"scene_id": request.scene["scene_id"]},
)
provider = FakeImageProvider()
assert isinstance(provider, providers.MediaProvider)
providers.register("fake-local", provider)
try:
assert providers.for_kind(providers.IMAGE) == [provider]
import asyncio
p = packet_of(client)
result = asyncio.run(provider.generate(
providers.MediaRequest(kind=providers.IMAGE, scene=p)
))
assert result.media_type == "image/png"
assert result.provenance["scene_id"] == p["scene_id"]
assert seen["scene_id"] == p["scene_id"]
finally:
providers.unregister("fake-local")
assert providers.registered() == {}
def test_every_required_media_kind_is_accommodated(client):
assert set(providers.MEDIA_KINDS) == {"image", "video", "audio", "tts", "stt"}
def test_a_request_for_an_unknown_kind_is_refused(client, office):
with pytest.raises(ValueError, match="hologram"):
providers.MediaRequest(kind="hologram", scene=packet_of(client))
def test_stt_returns_a_draft_and_not_a_result(client):
"""§10, and the reason the return type differs.
A transcription cannot be handed to something expecting a finished artefact,
because it is not one — it is text the reader is going to edit.
"""
class FakeStt:
def capabilities(self):
return providers.ProviderCapabilities(
provider_id="fake-stt", kinds=(providers.STT,))
async def transcribe(self, audio, hints=None):
return providers.DraftTranscription(text="i open teh door")
import asyncio
stt = FakeStt()
assert isinstance(stt, providers.TranscriptionProvider)
draft = asyncio.run(stt.transcribe(b"\x00\x01"))
assert isinstance(draft, providers.DraftTranscription)
assert not isinstance(draft, providers.MediaResult)
assert draft.editable is True
def test_an_stt_draft_has_no_route_into_the_story(client, office):
"""The corrected text enters the way anything the reader types does.
Asserted by playing the edited draft through the ordinary action endpoint
and observing that it is an ordinary turn — validated, refereed, snapshotted
— rather than by asserting that some bypass does not exist.
"""
draft = providers.DraftTranscription(text="i open teh door")
corrected = draft.text.replace("teh", "the")
before = len(client.get(f"/api/adventures/{client.adv_id}").json()["actions"])
m10_fixture.play(client, client.adv_id, corrected, [])
after = client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
assert len(after) == before + 2
assert after[-2]["text"].endswith("i open the door.")
def test_the_story_engine_holds_no_provider_vocabulary(client):
"""§9. Provider syntax must not appear in Story Engine code.
Greps rather than trusting the boundary, so a future adapter's vocabulary
cannot leak in unnoticed.
**`app/media/` is excluded, and the exclusion is the point rather than a
hole.** §9's rule is about the *Story Engine*; `media/` is the seam, and its
docstrings name ComfyUI, Whisper and `num_inference_steps` precisely in
order to say that those belong to a future adapter and not here. A grep that
failed on the sentence forbidding a thing would push the explanation out of
the code, which is the opposite of what the rule wants.
What would catch a violation inside `media/` is not this test but the shape
of the package: it registers no provider (`test_no_provider_is_registered`),
ships no adapter, and imports nothing that could reach one.
"""
import pathlib
root = pathlib.Path(__file__).resolve().parent.parent / "app"
seam = root / "media"
forbidden = ("comfyui", "stable diffusion", "stable-diffusion", "automatic1111",
"num_inference_steps", "cfg_scale", "denoising_strength",
"safetensors", "whisper", "kokoro", "flux.1")
offenders = []
for path in root.rglob("*.py"):
if seam in path.parents:
continue
lowered = path.read_text().lower()
for word in forbidden:
if word in lowered:
offenders.append(f"{path.relative_to(root)}: {word}")
assert offenders == [], offenders
def test_the_seam_ships_no_adapter(client):
"""The other half of the rule above, for `app/media/` itself.
The seam is allowed to *name* a provider in prose; it is not allowed to
*be* one. Checked by what it does rather than by what it says: no provider
registered, and no HTTP client imported anywhere in the package.
"""
import pathlib
assert providers.registered() == {}
seam = pathlib.Path(__file__).resolve().parent.parent / "app" / "media"
for path in seam.rglob("*.py"):
body = path.read_text()
for client_lib in ("import httpx", "import requests", "urllib.request",
"import socket", "subprocess"):
assert client_lib not in body, f"{path.name} imports {client_lib}"
# ------------------------------------------------------------ endpoint policy
def test_a_media_endpoint_must_be_loopback(client):
"""§11 and contract §27-28: stricter than the narrator's policy, on purpose."""
assert providers.endpoint_rejection_reason("http://127.0.0.1:8188") is None
assert providers.endpoint_rejection_reason("http://localhost:8188") is None
def test_a_trusted_lan_media_endpoint_is_refused(client):
"""Allowed for narrator inference; not for media, which has no v1 use."""
reason = providers.endpoint_rejection_reason("http://192.168.1.50:8188")
assert reason is not None
assert "on this machine" in reason
@pytest.mark.parametrize("url", [
"https://api.example.com/v1",
"http://8.8.8.8:8188",
"",
"not a url",
])
def test_a_non_local_media_endpoint_is_refused(client, url):
assert providers.endpoint_rejection_reason(url) is not None
def test_check_endpoint_raises_for_a_refused_endpoint(client):
with pytest.raises(providers.EndpointRejected):
providers.check_endpoint("https://api.example.com/v1")
providers.check_endpoint("http://127.0.0.1:8188")
# ------------------------------------------------------------------- K04
def test_k04_the_extension_point_a_future_asset_would_attach_through(client, office):
"""K04, on the acceptance text's **deferred** branch — see the M10 report §F.
No media tables exist, so this demonstrates the equivalent extension point
rather than a stored asset: a dummy local byte fixture is carried through
the provider contract, and the association it needs is proved to resolve.
What is actually asserted is the part that would matter to a real asset:
the provenance it carries names a scene, that name resolves to an accepted
position, and the story is untouched either side.
"""
p = packet_of(client)
before_state = state_of(client)
before_actions = client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
dummy = providers.MediaResult(
kind=providers.IMAGE,
media_type="image/png",
data=b"\x89PNG\r\n\x1a\n\x00fixture",
provenance={"scene_id": p["scene_id"],
"turn_range": p["turn_range"],
"campaign_id": p["campaign"]["id"]},
)
resolved = scene_packet.parse_scene_id(dummy.provenance["scene_id"])
assert resolved["adventure_id"] == client.adv_id
assert resolved["branch_id"] == p["turn_range"]["branch_id"]
# The position it names is a real accepted turn in this campaign.
with SessionLocal() as db:
found = db.query(models.Action).filter(
models.Action.adventure_id == client.adv_id,
models.Action.branch_id == resolved["branch_id"],
models.Action.depth == resolved["end"],
).count()
assert found >= 1
# And nothing about the story moved.
assert state_of(client) == before_state
assert client.get(f"/api/adventures/{client.adv_id}").json()["actions"] == \
before_actions
+341
View File
@@ -0,0 +1,341 @@
"""M10 §8, §19 and §20: the storyteller does not know the media layer is there.
Three claims, and the first is the milestone's central acceptance condition:
* **§20 — ordinary play is unchanged** with no media configuration of any kind.
Not "works with a warning", not "works once you dismiss something": unchanged.
* **§19 — nothing is contacted**, nothing is required at startup, and no
provider setting exists to be got wrong.
* **§8 — the hidden-information boundary.** A future provider must not receive
narrator-only material merely because the storyteller knows it.
The §8 tests use a **hidden M7 knowledge source**, which is this product's real
narrator-only mechanism, rather than an invented marker — so what is tested is
the boundary that exists. Each carries a **positive control**: the sentinel is
shown to reach the narrator's own prompt in the same campaign, so a passing test
cannot be one where the secret was never established.
python -m pytest tests/test_m10_no_media.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.media import providers
from app.routers import adventures
import m10_fixture
from fakes import ScriptedProvider
class StubDerived:
async def complete(self, system, prompt, **kwargs):
return "A memory of the meeting."
async def embed(self, texts):
out = []
for text in texts:
lowered = text.lower()
out.append([
1.0,
1.0 if "observer" in lowered or "panelling" in lowered else 0.0,
1.0 if "office" in lowered or "meeting" in lowered else 0.0,
])
return out
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
embeddings._cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="m10nm@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="",
context_token_budget=4000, max_output_tokens=400, memory_top_k=3,
))
adventure = models.Adventure(user_id=user.id, title="No media")
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start",
text="Bill badges in on a Tuesday morning.",
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
memorybank._vector_cache.clear()
embeddings._cache.clear()
Base.metadata.drop_all(bind=engine)
# ---------------------------------------------------- §20: unchanged play
def test_a_whole_campaign_plays_with_no_media_configuration(client):
"""§20's list, in one campaign, with no media anything.
Turns, state extraction, memory and summary activity, knowledge retrieval,
Undo, Redo, Retry, a Save Point restore, and a fresh read of what was
written — all of it while no provider is registered, no media endpoint is
configured, and no media table holds a row. The genuine process restarts
live in `test_m10_lineage.py`.
"""
adv = client.adv_id
assert providers.registered() == {}
m10_fixture.upload_handbook(client, adv)
m10_fixture.build(client, adv)
for i in range(3):
m10_fixture.play(client, adv, f"discuss item {i}", [])
point = client.post(f"/api/adventures/{adv}/checkpoints",
json={"name": "Mid-meeting", "note": ""})
assert point.status_code == 201, point.text[:300]
m10_fixture.play(client, adv, "the meeting runs long", [])
assert client.post(f"/api/adventures/{adv}/undo").status_code == 200
assert client.post(f"/api/adventures/{adv}/redo").status_code == 200
retried = client.post(f"/api/adventures/{adv}/retry")
assert retried.status_code == 200, retried.text[:300]
restored = client.post(
f"/api/adventures/{adv}/checkpoints/{point.json()['id']}/restore")
assert restored.status_code == 200, restored.text[:300]
import asyncio
asyncio.run(memorybank.run_post_turn(adv))
# Retrieval still works, and the state is intact.
report = client.get(f"/api/adventures/{adv}/context").json()
assert report["prompt"]["system"]
assert client.get(f"/api/adventures/{adv}/state").json()["document"]["entities"]
# Read back through a fresh session — the state is on disk, not in the
# request that wrote it. This is *not* a process restart: the genuine
# spawned-process restarts are in `test_m10_lineage.py`, which runs them
# with profiles written and packets built.
with SessionLocal() as db:
assert db.get(models.Adventure, adv).narrative_state["scene"]["summary"]
def test_no_media_row_exists_after_ordinary_play(client):
"""Media readiness is inert until something uses it."""
m10_fixture.build(client, client.adv_id)
for i in range(3):
m10_fixture.play(client, client.adv_id, f"turn {i}", [])
with SessionLocal() as db:
# The fixture writes two profiles deliberately; ordinary *play* writes
# none, which is the claim. Counting after a campaign built without the
# fixture's profile step would be the same assertion said less clearly.
played_only = models.Adventure(user_id=None, title="untouched")
db.add(played_only)
db.flush()
assert db.query(models.VisualProfile).filter(
models.VisualProfile.adventure_id == played_only.id).count() == 0
def test_the_prompt_is_unchanged_by_media_readiness(client):
"""M10 touches no prompt path, and the assembled prompt shows it.
The context builder is the one place a new subsystem would leak into every
turn. No section M10 could have added appears, and the packet's own
vocabulary is absent.
"""
m10_fixture.build(client, client.adv_id)
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
labels = {section["label"] for section in report["sections"]}
for absent in ("scene_packet", "visual_profile", "visual_profiles", "media"):
assert absent not in labels
blob = report["prompt"]["system"] + report["prompt"]["story"]
assert "visual_profile" not in blob
assert "scene_id" not in blob
def test_the_turn_path_does_not_import_the_media_package(client):
"""Structural: a turn cannot reach the media layer even by accident.
Checked on the modules' import statements rather than on their text, so the
test says "does not import the media package" and not "does not contain the
letters m-e-d-i-a" — which `immediately` would fail.
"""
import ast
import pathlib
root = pathlib.Path(__file__).resolve().parent.parent / "app"
for name in ("routers/adventures/turns.py", "context/builder.py",
"narrative/apply.py", "narrative/store.py", "tree.py",
"head.py", "memorybank.py"):
for node in ast.walk(ast.parse((root / name).read_text())):
if isinstance(node, ast.Import):
names = [a.name for a in node.names]
elif isinstance(node, ast.ImportFrom):
names = [node.module or ""] + [a.name for a in node.names]
else:
continue
assert not any(
n == "media" or n.endswith(".media") or n.startswith("media.")
for n in names
), f"{name} imports the media package"
# ----------------------------------------------------- §19: nothing outbound
def test_no_media_provider_is_required_at_startup(client):
"""The application imports, serves and plays with an empty registry."""
assert providers.registered() == {}
assert client.get("/api/health").json() == {"ok": True}
m10_fixture.play(client, client.adv_id, "play a turn", [])
def test_no_media_setting_exists_to_be_misconfigured(client):
"""§11's last clause: if no provider configuration is needed, none exists.
M10 invents no media endpoint setting, so there is nothing to point at a
cloud by mistake. The endpoint *policy* exists and is tested; a stored
endpoint does not.
"""
settings = client.get("/api/settings").json()
assert not any(
"media" in key or "image" in key or "video" in key or "tts" in key
or "stt" in key
for key in settings
), settings.keys()
assert not any(
"media" in column.name
for column in models.Settings.__table__.columns
)
def test_the_media_package_opens_no_socket(client):
"""§19: no new required outbound connection, checked by import.
`test_egress.py` owns the general no-outbound guarantee; this is the narrow
M10 claim that the new package could not participate in one.
"""
import pathlib
seam = pathlib.Path(__file__).resolve().parent.parent / "app" / "media"
for path in seam.rglob("*.py"):
body = path.read_text()
for forbidden in ("httpx", "requests.", "urlopen", "socket.socket",
"aiohttp", "subprocess"):
assert forbidden not in body, f"{path.name} references {forbidden}"
def test_a_media_endpoint_cannot_be_pointed_at_a_cloud(client):
"""The policy, applied where a future coordinator would apply it."""
for url in ("https://api.openai.com/v1", "http://8.8.8.8:8188",
"https://replicate.com", "http://example.com"):
assert providers.endpoint_rejection_reason(url) is not None
# ------------------------------------------- §8: the hidden-information line
def test_a_narrator_only_secret_does_not_reach_the_scene_packet(client):
"""§8, with a positive control.
The sentinel lives in a **hidden** imported source, which is the product's
narrator-only mechanism. The control proves it genuinely reaches the
narrator's prompt in this very campaign — so the packet's silence is a
boundary rather than an accident of the source never being retrieved.
"""
adv = client.adv_id
m10_fixture.upload_secret(client, adv)
m10_fixture.build(client, adv)
m10_fixture.play(client, adv, "look at the north wall panelling of the office", [])
report = client.get(f"/api/adventures/{adv}/context").json()
narrator_prompt = report["prompt"]["system"] + report["prompt"]["story"]
assert m10_fixture.SECRET_SENTINEL in narrator_prompt, (
"the control failed: the narrator was never told the secret, so the "
"packet's not containing it proves nothing"
)
packet = client.get(f"/api/adventures/{adv}/scene-packet").json()
assert m10_fixture.SECRET_SENTINEL not in repr(packet)
assert "concealed observer" not in repr(packet).lower()
def test_the_packet_carries_no_imported_source_even_when_visible(client):
"""The boundary is drawn by class, not by filtering secrets one at a time.
A *visible* reference source is excluded too, which is what makes the rule
hold for a secret nobody thought to mark: the packet never reads imported
knowledge at all, so there is no filter to forget to apply.
"""
adv = client.adv_id
m10_fixture.upload_handbook(client, adv)
m10_fixture.build(client, adv)
m10_fixture.play(client, adv, "ask about the north wall panelling", [])
report = client.get(f"/api/adventures/{adv}/context").json()
assert any("handbook" in r["filename"] for r in report["knowledge"]["used"]), (
"the control failed: the handbook never reached the narrator"
)
assert "refurbished" not in repr(
client.get(f"/api/adventures/{adv}/scene-packet").json())
def test_a_secret_the_story_accepted_does_reach_the_packet(client):
"""The other side of the line, and the reason the rule is the right one.
Once the *story* establishes something through a validated event, it is no
longer narrator-only knowledge — it is something that happened, at a
position, in the accepted state. A picture of that scene should show it, and
a packet that hid it would be hiding the story from itself.
"""
adv = client.adv_id
m10_fixture.upload_secret(client, adv)
m10_fixture.build(client, adv)
m10_fixture.play(client, adv, "the panel swings open", [
m10_fixture.entity("observer", "character", "The observer"),
{"type": "set_scene",
"summary": "The panel swings open and the observer steps out.",
"location": "office",
"present": ["bill", "alice", "roger", "observer"]},
])
packet = client.get(f"/api/adventures/{adv}/scene-packet").json()
assert "The observer" in [c["name"] for c in packet["characters"]]
# And still not the sentinel, which the story never said aloud.
assert m10_fixture.SECRET_SENTINEL not in repr(packet)
def test_memories_and_summaries_stay_out_of_the_packet(client):
"""§7's bound: derived narrative text about the past is not depiction input."""
import asyncio
adv = client.adv_id
m10_fixture.build(client, adv)
for i in range(8):
m10_fixture.play(client, adv, f"talk {i}", [],
prose=f"Roger recounts the printer incident again {i}.")
asyncio.run(memorybank.run_post_turn(adv))
packet = client.get(f"/api/adventures/{adv}/scene-packet").json()
assert "printer" not in repr(packet)
assert "memor" not in repr(packet).lower()
+225
View File
@@ -0,0 +1,225 @@
"""What M10 costs a campaign, measured rather than argued.
python -m tools.m10_media_cost [--turns 60]
Run from `backend/`. Plays a campaign of `--turns` turns with the real prompt
builder and the real state pipeline, then reports the five numbers §21 of the
M10 brief asks for.
Four of them are expected to be zero or near it, and that is the point: M10's
central design decision was that **the scene snapshot already exists**, so the
milestone persists nothing per scene and nothing per turn. A design claim like
that is cheap to make and easy to get wrong by one accidental write, so it is
measured here against a campaign long enough for a per-turn cost to show.
scene records written by M10 expected 0, and the scenes that do
exist are M5's, counted for contrast
bytes added to the database one row per profiled entity, once
profile duplication what per-position profiles would have
cost, against what campaign-scoped
profiles do cost
packet: persisted or constructed rows written while building one
current-scene query behaviour statements per packet, at 10 turns and
at N turns — a number that grows with
the campaign is a scan
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import time
from pathlib import Path
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE.parent / "tests"))
_DB = tempfile.NamedTemporaryFile(suffix="-m10-cost.db", delete=False)
_DB.close()
os.environ["AIDND_DB_PATH"] = _DB.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fastapi import Depends # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
import m10_fixture # noqa: E402
from app import auth, limits, memorybank, models # noqa: E402
from app.database import Base, SessionLocal, engine, get_db # noqa: E402
from app.main import app # noqa: E402
from app.routers import adventures # noqa: E402
from fakes import ScriptedProvider, state_block # noqa: E402
from tools import dbmeter # noqa: E402
PROSE = (
"Roger pulled the whiteboard marker apart while he talked, which was how "
"everyone knew the meeting had stopped being about the agenda. Alice wrote "
"nothing down. Outside the glass, somebody wheeled a trolley of monitors "
"past the door and did not look in."
)
class _Stub:
async def complete(self, system, prompt, **kwargs):
return "The meeting went on for some time."
async def embed(self, texts):
return [[1.0, 0.5, 0.25, 0.125] for _ in texts]
def _setup() -> tuple[TestClient, int]:
adventures.turns.OpenAICompatibleProvider = ScriptedProvider
memorybank.embedding_provider = lambda s: _Stub()
memorybank.summary_provider = lambda s: _Stub()
limits.check_row_cap = lambda *a, **k: None
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
user = models.User(is_guest=False, email="m10cost@example.com")
db.add(user)
db.flush()
db.add(models.Settings(
user_id=user.id, model="cost-model", embedding_model="stub",
context_token_budget=8192, max_output_tokens=600,
))
adventure = models.Adventure(user_id=user.id, title="Cost",
auto_summarize=True, memory_bank_enabled=True)
db.add(adventure)
db.flush()
db.add(models.Action(adventure_id=adventure.id, type="start",
text="Bill badges in on a Tuesday morning."))
db.commit()
adv_id, user_id = adventure.id, user.id
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
return TestClient(app), adv_id
def _db_bytes() -> int:
return Path(_DB.name).stat().st_size
def _counts(adv_id: int) -> dict:
with SessionLocal() as db:
return {
"actions": db.query(models.Action).filter(
models.Action.adventure_id == adv_id).count(),
"visual profiles": db.query(models.VisualProfile).filter(
models.VisualProfile.adventure_id == adv_id).count(),
"M5 per-position state snapshots": db.query(models.Action).filter(
models.Action.adventure_id == adv_id,
models.Action.narrative_state_after.isnot(None)).count(),
}
def _profile_bytes(adv_id: int) -> int:
with SessionLocal() as db:
rows = db.query(models.VisualProfile).filter(
models.VisualProfile.adventure_id == adv_id).all()
return sum(
len(json.dumps({"entity_key": r.entity_key,
"descriptors": r.descriptors,
"features": r.features,
"style_notes": r.style_notes}).encode("utf-8"))
for r in rows
)
def _packet_statements(client, adv_id: int, meter: dbmeter.Meter, label: str):
with meter.scope(label) as scope:
started = time.perf_counter()
response = client.get(f"/api/adventures/{adv_id}/scene-packet")
seconds = time.perf_counter() - started
response.raise_for_status()
return scope, seconds
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--turns", type=int, default=60)
args = parser.parse_args()
client, adv_id = _setup()
empty_bytes = _db_bytes()
m10_fixture.build(client, adv_id)
meter = dbmeter.Meter()
meter.attach(engine)
try:
early_scope, early_seconds = _packet_statements(
client, adv_id, meter, "packet at 2 turns")
before_play = _db_bytes()
for turn in range(1, args.turns + 1):
ScriptedProvider.replies = [
f"{PROSE} [{turn}]\n" + state_block([
{"type": "set_scene",
"summary": f"The meeting reaches item {turn}.",
"location": "office",
"present": ["bill", "alice", "roger"]},
])
]
client.post(f"/api/adventures/{adv_id}/actions",
json={"type": "do", "text": f"item {turn}"}
).raise_for_status()
rows_before = _counts(adv_id)
late_scope, late_seconds = _packet_statements(
client, adv_id, meter, f"packet at {args.turns + 2} turns")
rows_after = _counts(adv_id)
finally:
meter.detach()
played_bytes = _db_bytes()
profile_bytes = _profile_bytes(adv_id)
print(f"\n{args.turns} turns, {rows_before['actions']} action rows\n")
print("scene records M10 wrote")
print(f" visual_profiles rows {rows_before['visual profiles']:>8}"
" (one per profiled entity, written once)")
print(" scene rows 0"
" M10 adds no scenes table")
print(f" M5 per-position state snapshots "
f"{rows_before['M5 per-position state snapshots']:>8}"
" already there since M5; the scene lives here")
print("\nbytes added to the database")
print(f" empty database {empty_bytes:>8} B")
print(f" after the fixture campaign {before_play:>8} B")
print(f" after {args.turns} more turns".ljust(36)
+ f"{played_bytes:>8} B")
print(f" visual profile content {profile_bytes:>8} B"
f" {100 * profile_bytes / max(played_bytes, 1):.3f}% of the database")
per_position = profile_bytes * rows_before["M5 per-position state snapshots"]
print("\nprofile duplication: campaign-scoped against per-position")
print(f" as stored, once per entity {profile_bytes:>8} B")
print(f" if snapshotted per position {per_position:>8} B"
f" x{per_position / max(profile_bytes, 1):.0f}")
print("\npacket: persisted or constructed")
print(f" rows written while building one "
f"{rows_after['visual profiles'] - rows_before['visual profiles']:>8}")
print(" packet rows in any table 0 built on read, never stored")
print(f" build time, 2 turns {early_seconds * 1000:>8.1f} ms")
print(f" build time, {args.turns + 2} turns".ljust(36)
+ f"{late_seconds * 1000:>8.1f} ms")
print("\ncurrent-scene query behaviour")
print(f" statements, 2 turns {early_scope.total.statements:>8}")
print(f" statements, {args.turns + 2} turns".ljust(36)
+ f"{late_scope.total.statements:>8}")
verdict = ("does not grow with the campaign"
if late_scope.total.statements <= early_scope.total.statements
else "GROWS — the scene is being scanned, not read")
print(f" {verdict}")
print("\n" + dbmeter.render_scope(late_scope, statements=6))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61
View File
@@ -1345,6 +1345,67 @@ Do not implement:
Future media providers can be added through defined local interfaces without redesigning core story authority/history.
## Status: COMPLETE — 2026-09-07, pending independent review
Implemented on `m10-media-hooks` from the signed M9 commit `44edece`.
`planning/reports/M10-IMPLEMENTATION-REPORT.md` is the implementer's account,
written for a reviewer.
**The finding that shaped the milestone: the scene snapshot already existed.**
The media contract's §5 asks for a persisted or derived scene snapshot, and M5
built one three milestones ago. `narrative_state["scene"]` holds the summary, the
location, who is present and the `(branch_id, depth)` coordinate; it is written
by a validated `set_scene` event, snapshotted per position, and restored on every
head move. That was verified with a probe — a campaign played, diverged, undone
and exported — rather than taken from M9's report.
So M10 built **no scenes table**, and the Scene Packet is derived on read with a
computed identity (`c<adventure>:b<branch>:<start>-<end>`) rather than an
allocated one. A second scene store would have been a duplicate representation of
the same fact, with its own lineage rules to get wrong; the lineage rules are the
hard part, which is precisely the argument for reusing the ones that already
work.
**What it delivered:**
- **One table, `visual_profiles`** — the only field in the contract's scene list
that nothing already stored. Campaign-scoped rather than per-position, because
a character does not change appearance when the story forks, and because
per-position profiles would have cost 245 copies of the same 367 bytes in a
120-turn campaign to say something that never varies.
- **A scene packet built on read**, excluding the raw transcript, all imported
knowledge, memories and summaries. Excluding imported knowledge *as a class* is
what keeps a hidden Canon source out of a future depiction without a filter
anyone has to remember to extend.
- **Provider contracts as `typing.Protocol` structural types**, with an empty
registry, no adapter, and no dependency added. Nothing imports a media library
because none is installed.
- **The STT asymmetry in the type**: a `DraftTranscription` is editable and has
no commit method, so a transcriber structurally cannot bypass the authoritative
commit path.
- **A stricter endpoint policy than narration uses** — loopback only, reusing
`endpoints.py`'s resolved-address check rather than trusting a hostname. No
media configuration setting exists, because one that exists can be pointed at a
cloud by mistake.
- **Profiles travel in the bundle** with no format bump. M9's own semantic test
decides it: an absent `visualProfiles` key is unambiguous, because a campaign
with no profiles is the ordinary case. Older v3 files still import.
**A defect found by the milestone's own tests, and fixed here:**
- **A redundant index migration made two databases disagree.** M10 first shipped
migration 93 creating `ix_visual_profiles_adventure`. `create_all` already
builds `ix_visual_profiles_adventure_id` from the column's `index=True`, on
fresh installs and existing databases alike — so an *upgraded* database ended
up with both indexes and a fresh one with only the second. The comparison of a
fresh schema against an upgraded schema is what caught it. **M10 adds no
migration at all**; `LATEST_VERSION` stays 92.
**Carry-forward, unchanged by this milestone:** the four post-M8 playtest
findings recorded above (browser title, post-Undo orientation, narration length,
character identity) remain **M11's**. M10 neither implemented nor tested them.
---
# M11 — v1 Security, Long-Run, and Release Validation
+55
View File
@@ -829,6 +829,61 @@ media_asset:
Potential metadata includes prompt, seed, model, workflow, dimensions, duration, character references, and source turn range.
## 28A. Media Extension Points (M10, as implemented)
M10 implemented the seams the two sections above describe, and the implementation
is mostly an account of what it did **not** build.
```text
visual_profiles the one thing M10 persists
id, adventure_id campaign-scoped; no branch coordinate, deliberately
entity_key the M5 narrative-state key: "mara", "the_office"
descriptors open map of trait -> value
features list of distinctive visible things
style_notes free text about how it should be rendered
created_at, updated_at
UNIQUE (adventure_id, entity_key)
```
No `scenes` table. No `media_jobs` table. No `media_assets` table.
**The scene snapshot of §20 already exists**, and has since M5. It is
`narrative_state["scene"]` — `summary`, `location`, `present[]`, and the
`at: {branch_id, depth}` coordinate that says where it was written. It is
produced by the validated `set_scene` event, snapshotted per position in
`actions.narrative_state_after`, restored by the head move on every Undo, Redo,
Retry and Save Point restore, and carried in the v3 bundle. Building a second
scene record beside it would have been a duplicate representation of the same
fact with its own lineage rules to get wrong — and the lineage rules are the hard
part, which is exactly why the answer is to reuse the one that already works.
So the **Scene Packet** (`app/media/packet.py`) is *derived on read* and stored
nowhere. Its identity is `c<adventure>:b<branch>:<start>-<end>`, computed from
the campaign and the position rather than allocated, so the same position yields
the same id in any process and after any restart without a row to keep in step.
`media_job` and `media_asset` (§27, §28) remain **unbuilt**. M10 defines their
contracts as `typing.Protocol` structural types in `app/media/providers.py` —
`MediaProvider`, `SpeechProvider`, `TranscriptionProvider`, and the
`MediaRequest` / `MediaResult` / `DraftTranscription` shapes — with an empty
registry. A queue with no producer and no consumer would be speculative
architecture, and this codebase has already declined that once: M6's
`derived_status` carries the note "not a job queue".
**Why a visual profile has no branch coordinate.** Every other derived record in
the schema carries `(branch_id, depth)` because it describes a *moment*. A
profile describes none: a character does not change appearance because the story
forked. Making it per-position would have hidden a reader's cast from them the
moment they diverged, and would have put a descriptor document into every
per-position snapshot — measured at 245 copies of 367 bytes in a 120-turn
campaign, to say something that never varies (`tools/m10_media_cost.py`).
**A profile is not a fact.** Nothing here is state the story established.
Writing one cannot change `narrative_state`, and the guarantee is structural
rather than remembered: nothing in `app/media/` imports the code that writes it.
The reverse direction is the same rule seen from the other side — a depiction
never becomes canon (`MEDIA-EXTENSION-CONTRACT.md` §35, §37).
## 29. Export Package
A campaign export should be capable of preserving:
+119
View File
@@ -1420,3 +1420,122 @@ Optional Media Coordinator
```
No production media provider is required for v1.
## 90. As Implemented (M10)
The contract above is Phase 0B design. This section records what M10 built
against it, what it deliberately left unbuilt, and the three places where
implementation answered a question the contract left open. It is appended rather
than woven in, so the original contract stays readable as the document it is.
### 90.1 What was built
```text
app/media/packet.py §10-11 the scene packet, derived on read
app/media/profiles.py §7-9 visual profiles for any entity
app/media/providers.py §13, §21-28, §55 the contracts and the endpoint policy
app/models.py VisualProfile — the only table M10 adds
app/routers/adventures/visuals.py six endpoints, all read/write of the above
```
Four HTTP endpoints for profiles (list, read, write, delete) and one for the
packet. No coordinator, no queue, no worker, no provider adapter, no dependency
added.
### 90.2 §5 was already satisfied — the scene snapshot exists
The single most consequential finding of the milestone. §5 requires a persisted
or derived scene snapshot; **M5 had already built it**, and it has been carrying
lineage correctly for three milestones. `narrative_state["scene"]` holds the
summary, the location, who is present, and the `(branch_id, depth)` coordinate;
it is written by a validated `set_scene` event, snapshotted per position, and
restored on every head move.
That was verified rather than assumed — a probe played a campaign, diverged it,
and checked that the scene at each position was the scene that position had, that
Undo cleared it back to the state before, and that a bundle carried both
branches' scenes.
So M10 built **no scenes table**. §6 says the scene snapshot must never be
authoritative over the story; deriving it from the authoritative state on read is
the strongest available form of that guarantee, because there is no second copy
that could disagree.
### 90.3 §12 answered: the packet excludes more than the transcript
§12 says the packet must not require raw transcript access. The implementation
draws the line wider, and this is a deliberate reading rather than an omission.
The packet carries **what the story established at this position**: location,
present characters with their profiles, significant objects, an action summary,
continuity constraints, ambience, turn range, lineage. It excludes the raw
transcript, **all imported knowledge** (§7 of `IMPORTED-KNOWLEDGE-DESIGN.md`),
memories and summaries.
Excluding imported knowledge as a *class* is what makes the hidden-information
rule hold. A narrator-only Canon source — the mechanism a reader uses to keep a
secret from themselves — never reaches a depiction, and does not need a filter
that someone must remember to apply to each new secret. Once the story
*establishes* something through a validated event it is no longer narrator-only,
and it appears in the packet, because at that point it is something that
happened rather than something the narrator was told.
### 90.4 §14-15 left unbuilt, and why
`MediaJob` and `MediaAsset` are defined as contracts (`MediaRequest`,
`MediaResult`, `ProviderCapabilities`) and not as tables. A job queue with no
producer and no consumer would be speculative architecture whose shape would be
decided by a provider nobody has chosen yet; the codebase declined the same thing
once already, in M6's `derived_status` ("not a job queue"). §16-20, §45-47 —
provenance, cleanup, retries, lineage — are therefore also deferred, and they
should be designed against a real coordinator.
What M10 does guarantee for them is the part that would be expensive to retrofit:
the scene identity a future asset must reference (`c<adventure>:b<branch>:<start>-<end>`)
is derived from the campaign and position rather than allocated, so it is stable
across processes, restarts and re-derivation without a row to keep in step.
### 90.5 §7-9 collapsed into one table, deliberately
The contract describes character, location and item profiles in three sections.
The implementation has one `visual_profiles` table keyed by the M5 entity key,
because M5's entity model is genre-neutral by design and a character, a location,
an item, a vehicle and a spaceship are all entities with a `type`. Three tables —
or one table with a `kind` column duplicating the entity's own `type` — would
have reintroduced the genre shape M5 spent a milestone removing.
The fields are open by construction: `descriptors` is a trait map, `features` a
list, `style_notes` free text. `{"hair": "dark auburn"}` and
`{"hull": "pitted white composite"}` are the same shape. The contract's examples
are fantasy-shaped and the test fixture is deliberately not
(`backend/tests/m10_fixture.py`: four people in an office), because a schema
written while looking at hair and oil lamps acquires that shape without anyone
choosing it.
### 90.6 §27-28 implemented strictly
A media provider endpoint must be **loopback**. `providers.endpoint_rejection_reason`
reuses the local-only policy in `app/endpoints.py` — which resolves the address
rather than trusting the hostname — and then requires loopback in addition. This
is stricter than narrator inference, which permits a trusted LAN host: a GPU
rendering a reader's campaign is a machine that reader is sitting at. No TLS
verification bypass exists anywhere in the path.
There is no provider configuration setting, because none is needed yet, and a
setting that exists can be pointed at a cloud by mistake.
### 90.7 §24A implemented as an asymmetry in the type
`TranscriptionProvider.transcribe` returns a `DraftTranscription` carrying
`editable: bool = True` and **no commit method**. A transcriber can produce a
draft and structurally cannot submit one. §24A's rule — STT never bypasses the
authoritative commit path — is therefore enforced by the shape of the interface
rather than by a caller remembering it.
### 90.8 §35 and §37 hold structurally
Nothing in `app/media/` imports the code that writes narrative state, no media
event type exists in the state vocabulary, and every M10 test that touches the
media layer compares the authoritative document before and after and requires it
to be identical (`backend/tests/test_m10_authority.py`). A depiction cannot
become canon because there is no path by which it could.
+38 -19
View File
@@ -3,8 +3,8 @@
**This file is the index. Start here.**
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
milestones **M1 through M8 implemented and accepted**, and **M9 implemented and
awaiting review**. M1-M6 were accepted on the dates below (M3 and M4: 2026-09-03;
milestones **M1 through M8 implemented and accepted**, and **M9 and M10
implemented and awaiting review**. M1-M6 were accepted on the dates below (M3 and M4: 2026-09-03;
M5: 2026-09-04; M6: 2026-09-06). M5 and M6 were each accepted only after an
independent review found a real defect and a corrective pass fixed it.
@@ -27,8 +27,16 @@ a reviewer: a set of claims with the measurements attached, not yet a record of
acceptance. M8's report has moved to `archive/milestone-reports/`, which is
where a milestone report goes once the next milestone's report replaces it.
**Next: M10 — Future Media Extension Hooks Only.** It has not been started, and
no brief for it exists.
**M10 — Future Media Extension Hooks Only — is implemented and awaiting
independent review** (2026-09-07). `reports/M10-IMPLEMENTATION-REPORT.md` is the
implementer's account. It built the seam and no media: one `visual_profiles`
table, a scene packet derived on read, provider contracts with an empty
registry, and no dependency added. Its central finding is that the scene
snapshot the media contract asks for **already existed**, built by M5.
**Next: M11 — v1 Security, Long-Run, and Release Validation.** It has not been
started, and no brief for it exists. It also owns the four post-M8 hands-on
playtest findings recorded in `BUILD-MILESTONES.md`.
**Package version:** see `VERSION.md`, which records what each revision changed
and why.
@@ -90,7 +98,7 @@ Two standing qualifications:
| Document | What it is for |
| --- | --- |
| `SPECIFICATION.md` | What the product must do. The top of the authority order. |
| `TECHNICAL-DESIGN.md` | The selected architecture, including what M1-M9 built, recorded as fact. |
| `TECHNICAL-DESIGN.md` | The selected architecture, including what M1-M10 built, recorded as fact. |
| `DATA-MODEL.md` | Entities, the stored head, branch disposition, and the v3 export contract. |
| `STORY-BRANCH-SEMANTICS.md` | Undo/Redo/Retry/branch/take behavior, including the M3 ratifications. |
| `CONTEXT-AND-MEMORY.md` | Prompt assembly, summarization, branch-safe memory. |
@@ -118,9 +126,10 @@ Two standing qualifications:
10. `BROWSER-UX-SPEC.md`
11. `V1-ACCEPTANCE-TESTS.md`
12. `DECISIONS/` — all of them; they are short.
13. `reports/M9-IMPLEMENTATION-REPORT.md`, for what the most recent milestone
actually left behind — reading it as a claim to check, not a record, until
it is reviewed. Nothing in `planning/archive/` unless sent there.
13. `reports/M10-IMPLEMENTATION-REPORT.md` and
`reports/M9-IMPLEMENTATION-REPORT.md`, for what the most recent milestones
actually left behind — read as claims to check, not records, until they are
reviewed. Nothing in `planning/archive/` unless sent there.
## Architectural decisions
@@ -151,14 +160,19 @@ work until Phase 0 closes — which Phase 0 satisfied on 2026-09-01. It is in
`reports/` holds the report for the milestone most recently completed, because
that is the one the next milestone's planning has to consult:
- `reports/M10-IMPLEMENTATION-REPORT.md` — the M10 implementation: the media
seam, everything it deliberately did not build, and the evidence for K01-K04.
Written by the implementer for an independent reviewer, so it is a set of
claims with the measurements attached and **not** a record of acceptance.
- `reports/M9-IMPLEMENTATION-REPORT.md` — the M9 implementation: the measured M8
portability baseline it started from, the final bundle contract, and the
evidence for every acceptance test it claims. Written by the implementer for
an independent reviewer, so it is a set of claims with the measurements
attached and **not** a record of acceptance. Its §W carries the M10-M11
handoff.
evidence for every acceptance test it claims. Its §W carries the M10-M11
handoff and its §Y holds the post-M8 playtest findings.
**It stays here until M10's report replaces it.**
**It stays here rather than moving to the archive**, against the usual
rotation, because M9 has not been accepted yet: a reviewer of either milestone
needs it, since M10 built on M9 and its baseline is M9's. It moves once M9 is
accepted.
Completed earlier milestones are in `archive/milestone-reports/`, which M8's
report joined when M9's was written: a milestone report is useful during the
@@ -293,20 +307,25 @@ Milestone M9 COMPLETE — awaiting review (2026-09-07)
migration hardening bundle format v3; SQLite online backup
|
v
Milestone M10 NEXT — not started
future media extension hooks see BUILD-MILESTONES.md
Milestone M10 COMPLETE — awaiting review (2026-09-07)
future media extension hooks reports/M10-IMPLEMENTATION-REPORT.md
scene packet derived, not stored; no media
|
v
Milestone M11 see BUILD-MILESTONES.md
Milestone M11 NEXT — not started
v1 security, long-run, release see BUILD-MILESTONES.md
validation also owns the post-M8 playtest findings
```
## Stop Rule
**One milestone at a time. Do not begin a milestone before its brief exists.**
**No M10 brief has been prepared**, and M9 is not accepted — it is implemented
and awaiting an independent review. Writing the M10 brief is the action after
that review closes, informed by the M9 report's §W.
**No M11 brief has been prepared**, and neither M9 nor M10 is accepted — both
are implemented and awaiting independent review. Writing the M11 brief is the
action after those reviews close, informed by the M9 report's §W, the M10
report's handoff, and the four post-M8 playtest findings in
`BUILD-MILESTONES.md`.
All three questions the M8 debt raised against M9 are settled and recorded:
the bundle carries historical context snapshots (`DATA-MODEL.md` §29); story
+37
View File
@@ -766,6 +766,43 @@ The browser should access them through:
Do not expose arbitrary filesystem browsing.
## 42A. Media Endpoints As Implemented (M10) — narrower than this document allows
M10 built the media seam and **did not widen the trust boundary**. Two notes,
because in one place the implementation is deliberately stricter than the text
above, and a stricter implementation than the threat model describes is still a
discrepancy worth writing down.
**Media endpoints are loopback only.** §73's pass condition permits "explicitly
configured trusted-LAN Ollama/media endpoints". `providers.endpoint_rejection_reason`
allows that for narration and refuses it for media: it applies the shared
local-only policy in `app/endpoints.py` — which resolves the address rather than
trusting the hostname, so `localhost.evil.example` does not pass — and then
requires loopback in addition. The reasoning is that a GPU rendering someone's
campaign is a machine that person is sitting at, and that a picture of a scene
carries the scene with it. If a later milestone finds a real trusted-LAN media
use, that is a decision to make explicitly, not a limit to relax quietly.
**Nothing is contacted, and nothing can be configured to be.** M10 adds no
provider adapter, no HTTP client, and no media endpoint *setting* — a setting
that exists is a setting that can be pointed at a cloud by mistake. The registry
ships empty; no module under `app/media/` imports `httpx`, `requests`,
`urllib.request`, `socket`, `aiohttp` or `subprocess`, and that is asserted by
test rather than by inspection (`backend/tests/test_m10_no_media.py`). No TLS
verification bypass exists anywhere in the path.
**§41's media-metadata concern is unreached**, because no media is generated and
no asset is stored. It stays open for whichever milestone builds a coordinator.
**One new boundary that is not a network one.** The Scene Packet is the input a
future provider would receive, so what it carries is a disclosure decision. It
carries what the *story* established at a position and excludes the raw
transcript, all imported knowledge, memories and summaries — so a hidden Canon
source (§56's story secrets) cannot reach a depiction. The exclusion is by class
rather than by filtering marked secrets, which is what makes it hold for a secret
nobody thought to mark. Tested with a sentinel in a hidden source, alongside a
positive control proving the narrator did receive it.
## 43. Logging
Logs should minimize story-content exposure.
+58
View File
@@ -1098,6 +1098,64 @@ microphone/audio -> local STT -> editable draft -> normal user submission
STT never bypasses the ordinary authoritative story commit path.
### 15.1 As implemented (M10)
The boundary above is built. What follows is what it turned out to be.
**The scene snapshot was already there.** §15 says "persist or derive", and the
answer is *derive*, because M5 had persisted it three milestones earlier:
`narrative_state["scene"]` holds `summary`, `location`, `present[]` and the
`at: {branch_id, depth}` coordinate, written by the validated `set_scene` event
and snapshotted per position. It already restores correctly through Undo, Redo,
Retry, Save Point restore and divergence, because the head move restores the
whole state document and the scene is part of it. A second scene store would
have had to reimplement all of that, and would have been a second answer to
"where is the story now".
So the seam is three modules under `app/media/`, and only one of them has a
table:
```text
app/media/packet.py the normalized scene packet §15 names, built on read
app/media/profiles.py visual character/location profiles — the one field in
§15's list that nothing already stored
app/media/providers.py the contracts a future coordinator implements
```
`GET /api/adventures/{id}/scene-packet` returns the packet; the four
`visual-profiles` endpoints read and write profiles. Nothing else in the
application calls either — no turn, no prompt, no context section.
**What the packet contains, and the rule behind it.** Location, characters
present with their profiles, significant objects, an action summary, continuity
constraints, ambience, the source turn range and the lineage coordinate. What it
deliberately excludes is the more interesting half: the raw transcript, all
imported knowledge, memories and summaries. The rule is *what the story
established at this position*, not *everything the narrator was told* — which is
what keeps a hidden Canon source out of a depiction without needing a filter
that someone has to remember to apply to each new secret.
**Story engine functional with media disabled** is the milestone's central
acceptance condition rather than a footnote, and it is tested as one
(`backend/tests/test_m10_no_media.py`): a whole campaign — turns, state
extraction, memory and summary activity, knowledge retrieval, Undo, Redo, Retry,
Save Point restore, restart — with an empty provider registry, no media setting
in existence, and no media row written. Media readiness is inert until something
uses it, and nothing does yet.
**STT asymmetry.** The `TranscriptionProvider` contract returns a
`DraftTranscription` with `editable: bool = True` and no commit method, so the
diagram above is enforced by the shape of the interface: a transcriber can
produce a draft and cannot submit one. The ordinary authoritative commit path is
the only way in.
**Endpoint policy.** A future media provider endpoint is checked by
`providers.endpoint_rejection_reason`, which reuses the local-only policy in
`app/endpoints.py` and then requires loopback in addition — stricter than
narrator inference, which permits a trusted LAN host. A GPU that renders a
reader's campaign is a machine that reader is sitting at. No provider
configuration setting exists to point anywhere, because none is needed yet.
## 16. Database Direction
SQLite remains the selected v1 authoritative store.
+68
View File
@@ -2084,6 +2084,24 @@ Reach a scene involving multiple characters and a clear location.
### Pass
Application can persist a structured scene representation sufficient for future media use.
### Result — PASS, and it was already passing (M10, 2026-09-07)
The scene representation is `narrative_state["scene"]` and has existed since M5:
`summary`, `location`, `present[]`, and the `at: {branch_id, depth}` coordinate
that says where it was written. It is produced by the validated `set_scene`
event, snapshotted per position in `actions.narrative_state_after`, restored on
every head move, and carried in the v3 bundle.
M10 verified this with a probe rather than trusting the earlier report — a
campaign played to a scene, diverged, undone and exported — and then built the
normalized packet on top of it (`app/media/packet.py`,
`GET /api/adventures/{id}/scene-packet`) instead of a second scene store.
Tests: `backend/tests/test_m10_media_hooks.py` (the packet's contents and
bounds), `test_m10_lineage.py` (the scene follows the active lineage through
Undo, Redo, Retry, divergence, Save Point restore and two genuine process
restarts).
---
## K02 — Visual Character Profile
@@ -2093,6 +2111,22 @@ Application can persist a structured scene representation sufficient for future
### Pass
Character can retain optional stable visual descriptors.
### Result — PASS (M10, 2026-09-07)
`visual_profiles`, keyed by `(adventure_id, entity_key)` — the M5 entity key, not
a new identity namespace — with an open `descriptors` map, a `features` list and
free `style_notes`. Read and written through
`/api/adventures/{id}/visual-profiles[/{entity_key}]`.
**Optional** is tested as well as stated: the fixture leaves one character
deliberately unprofiled, and the packet reports `visual_profile: null` for them
rather than an empty profile, because "nobody has decided what Roger looks like"
and "Roger looks like nothing" are different answers to a future provider.
**Stable** means campaign-scoped rather than per-position: a character does not
change appearance because the story forked, so a profile survives Undo, Redo,
divergence and Save Point restore unchanged, and travels in the bundle.
---
## K03 — Visual Location Profile
@@ -2102,6 +2136,19 @@ Character can retain optional stable visual descriptors.
### Pass
Location can retain optional visual continuity descriptors.
### Result — PASS, through the same mechanism as K02 (M10, 2026-09-07)
There is no separate location table. M5's entity model is genre-neutral and a
location is an entity with a `type`, so one `visual_profiles` table serves
characters, locations and items alike. Splitting them would have reintroduced the
genre shape M5 spent a milestone removing, and a `kind` column would have been a
second copy of the entity's own type.
The fixture is deliberately non-fantasy — an open-plan office under flat
fluorescent light — because the contract's examples are fantasy-shaped and a
schema written while looking at them acquires that shape without anyone choosing
it.
---
## K04 — Attach Media Asset to Scene
@@ -2116,6 +2163,27 @@ A local dummy/test image can be associated with a scene/turn without altering st
If media tables are deferred:
- architecture/types should demonstrate equivalent extension point.
### Result — PASS on the deferred branch (M10, 2026-09-07)
Media tables are **deferred deliberately**, so this is reported against the
acceptance text's second clause rather than its first.
`app/media/providers.py` defines the extension point as structural contracts:
`MediaRequest`, `MediaResult`, `ProviderCapabilities`, `DraftTranscription`, and
the `MediaProvider` / `SpeechProvider` / `TranscriptionProvider` protocols, with
a registry that is empty and stays empty. A test registers a dummy provider,
builds a scene packet, generates a fake PNG carrying the packet's `scene_id` as
provenance, and confirms the story model is byte-for-byte unchanged — which is
the equivalence the clause asks for.
`media_jobs` and `media_assets` were not built because a queue with no producer
and no consumer would be speculative architecture, shaped by a provider nobody
has chosen; M6 declined the same thing (`derived_status`: "not a job queue").
The part that would be expensive to retrofit is guaranteed now: the scene
identity a future asset must reference is *derived* from campaign and position
(`c<adventure>:b<branch>:<start>-<end>`), so it is stable across processes and
restarts without a row to keep in step.
---
## K05 — Generate Local Image
+39 -2
View File
@@ -1,8 +1,45 @@
# Planning Package Version
- **Package:** Adventure Storyteller Planning Package v3.5
- **Package:** Adventure Storyteller Planning Package v3.6
- **Revision date:** 2026-09-07
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M8 implemented and accepted**; **M9 implemented and awaiting independent review** (2026-09-07). M10 has not been started.
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M8 implemented and accepted**; **M9 and M10 implemented and awaiting independent review** (2026-09-07). M11 has not been started.
## v3.6 — M10 implemented: future media extension hooks only (2026-09-07)
M10 built the media seam and no media. The documentation change is mostly a
record of what was deliberately **not** built, because that is the part a later
reader will otherwise re-litigate.
| Document | Change | Kind |
| --- | --- | --- |
| `DATA-MODEL.md` | **New §28A**, the media extension points as implemented: one `visual_profiles` table, no scenes table, no job or asset tables, and why each. | as-implemented record |
| `TECHNICAL-DESIGN.md` | **New §15.1** under the Scene and Future Media Boundary: what the seam turned out to be, what the packet carries and excludes, the STT asymmetry, the endpoint policy. | as-implemented record |
| `MEDIA-EXTENSION-CONTRACT.md` | **New §90**, appended rather than woven in so the Phase 0B contract stays readable. Records the three places implementation answered an open question, and the sections left unbuilt. | as-implemented record |
| `BUILD-MILESTONES.md` | **M10 status block**: the finding that shaped the milestone, what shipped, and the migration defect its own tests caught. | milestone status |
| `V1-ACCEPTANCE-TESTS.md` | **K01-K04 results.** K01 passes and *was already passing*; K04 is reported on the acceptance text's deferred branch, as its own wording provides for. | acceptance evidence |
| `SECURITY-THREAT-MODEL.md` | **New §42A.** The trust boundary did not widen; in one place the implementation is deliberately **narrower** than §73 permits, which is still a discrepancy worth recording. | boundary note |
| `README.md`, `DEVELOPMENT.md` | The `media/` package in the architecture map, the backend test count (1,189), and the stricter rule a future media endpoint will meet. | developer docs |
| `reports/M10-IMPLEMENTATION-REPORT.md` | New. The implementer's account, written for a reviewer. | milestone report |
**The decision behind the whole milestone**: the scene snapshot the media
contract asks for **already existed** as `narrative_state["scene"]`, built by M5
and carrying lineage correctly since. Verified with a probe rather than taken
from an earlier report. So M10 added no scenes table, and derives the scene
packet on read.
**Two decisions recorded rather than assumed:**
- **The bundle format stays `ai-dnd-adventure-v3`.** M9's own semantic test —
does omission create ambiguity about what an older file *could* have recorded?
— says no: a campaign with no visual profiles is the ordinary case, so an
absent key unambiguously means "none". Older v3 files still import.
- **M10 adds no migration.** A `CREATE INDEX` migration was written first and
removed: `create_all` already builds the table and the index declared on its
column, so the migration left an upgraded database holding an index a fresh
install did not have. `LATEST_VERSION` stays 92.
**The four post-M8 playtest findings below remain M11's** and are untouched by
this entry.
## v3.5 — Post-M8 hands-on playtest findings recorded (2026-09-07)
@@ -0,0 +1,855 @@
# M10 — Future Media Extension Hooks Only
**Implementation report, written for an independent reviewer.**
Branch `m10-media-hooks`, from the signed M9 commit `44edece`. Implemented
2026-09-07. This is a set of claims with the evidence attached; it is not a
record of acceptance.
**The one-sentence version:** the scene snapshot the media contract asks for
already existed, built by M5, so M10 built the seam around it and no media —
one table, a packet derived on read, provider contracts with an empty registry,
no dependency, no network, and no change to a single reader-facing surface.
---
## A. Repository baseline
| | |
| --- | --- |
| **M9 base commit** | `44edece67e7f65bacf78010ef56f98c3c8864073` — *"M9: a campaign you can actually get back"* |
| **Signature** | `git verify-commit 44edece` → **Good signature**, RSA key `02C9BF7D8A4A77DF7A8905617D8AE19DB5C68569`, "JesseMarkowitz", trust `[ultimate]`. `%G?` = `G`. |
| **Branch** | `m10-media-hooks`, created from that commit. The working tree was clean at the start. |
| **Upstream ancestry** | `upstream` = `https://github.com/parththakkar106/AI-DnD.git`. `git merge-base --is-ancestor d72f7c1b HEAD` → true: the fork point is still an ancestor, so this remains a fork rather than a rewrite. |
| **License / provenance** | `LICENSE` unchanged — md5 `07fde30437134836e2ee875e82a7cd31`, still MIT, still "Copyright (c) 2026 Parth Thakkar". `PROVENANCE.md` unchanged: M10 adds only new files under `backend/app/media/`, one router, one model and tests. **No dependency was added** to `requirements.txt` or `package.json`. |
---
## B. Architecture implemented
### B.1 The finding that decided the shape of the milestone
`MEDIA-EXTENSION-CONTRACT.md` §5 asks for a persisted or derived scene snapshot
carrying location, participants, objects, actions, ambience, profiles,
continuity constraints, and a turn range with lineage.
**Most of it already existed, and had since M5.** `narrative_state["scene"]`
holds:
```json
{"summary": "...", "location": "office", "present": ["bill", "alice", "roger"],
"at": {"branch_id": 3, "depth": 4}}
```
written by the validated `set_scene` typed event, snapshotted per position in
`actions.narrative_state_after`, restored by `attempts.restore_state` on every
head move, and carried in the M9 v3 bundle.
That was **verified rather than inherited from M9's report**: a probe played a
campaign to a scene ("Mara enters the cellar" at branch 1, depth 4), undid it and
confirmed the scene cleared to `{}`, diverged to a second continuation ("Mara
remains upstairs" at branch 3, depth 4), confirmed both were retained and
distinguishable, and confirmed a bundle carried both.
So **M10 created no scenes table.** A second scene store would have been a
duplicate representation of the same fact, with its own lineage rules to get
wrong — and the lineage rules are the hard part, which is the argument for
reusing the ones that already work rather than against it.
### B.2 What exists now
```text
backend/app/media/__init__.py 66 lines the finding, recorded where a
future implementer will hit it
backend/app/media/packet.py 328 lines the Scene Packet, built on read
backend/app/media/profiles.py 220 lines visual profiles: the one thing
§5 asks for that nothing stored
backend/app/media/providers.py 332 lines contracts + endpoint policy
backend/app/routers/adventures/visuals.py 131 four profile endpoints + the packet
backend/app/models.py +1 model VisualProfile
```
**Scene representation** — derived, not stored. `packet.build(db, adventure,
start=, end=)` reads `narrative_store.current(adventure)`, which is the
authoritative document at the active head, and returns:
```text
scene_id derived identity, below
campaign {id, title}
turn_range {branch_id, start, end}
lineage the head-capped branch lineage, as coordinates
location entity view + its visual profile, or null
characters present entities, each with its profile or null
objects significant items, held or in the location
action_summary the scene's own summary text
continuity_constraints what must stay true in a depiction
ambience {time_of_day, lighting, mood} — present, empty, §B.5
source {packet_version: 1, head_depth}
```
**Scene identity** — `c<adventure>:b<branch>:<start>-<end>`, e.g.
`c7:b3:4-4`. **Derived, not allocated.** The same position yields the same id in
any process, after any restart, and after the packet is thrown away and rebuilt,
with no row to keep in step. `parse_scene_id()` reads it back. This is the part
of a future `media_assets` table that would be expensive to retrofit, so it is
guaranteed now even though the table is not built.
**Turn-range representation** — depths on the branch the scene was set on.
Defaults to the scene's own single position; a caller passing `start` and `end`
describes a stretch, which is what a future video provider would ask for
(§31 of the contract, multi-turn packets).
**Lineage association** — the packet carries `turn_range.branch_id` and the
head-capped `lineage` array. The scene's coordinate comes from the scene's own
`at`, not from the head, and the difference is deliberate: a story can move on
without re-establishing the scene, and a picture belongs to the moment the scene
was set rather than to a later turn that did not change it.
**Visual profiles** — `visual_profiles`, keyed `(adventure_id, entity_key)`,
with an open `descriptors` map, a `features` list and free `style_notes`. Four
endpoints under `/api/adventures/{id}/visual-profiles` — list, read, write,
delete. Campaign-scoped, not
per-position (§D, §C.3).
**Provider-neutral interfaces** — `typing.Protocol` structural types, so a
future adapter satisfies them by shape and imports nothing from here:
```text
MediaProvider capabilities() -> ProviderCapabilities
generate(MediaRequest) -> MediaResult
SpeechProvider speak(...) -> MediaResult
TranscriptionProvider transcribe(...) -> DraftTranscription
```
with `MediaRequest`, `MediaResult`, `ProviderCapabilities`, `DraftTranscription`
and `MediaProviderError` beside them, and a registry (`register`, `unregister`,
`registered`, `for_kind`) that **is empty and ships empty**.
**STT draft-input contract** — `DraftTranscription` carries
`editable: bool = True` and **has no commit method**. A transcriber can produce a
draft and structurally cannot submit one; the ordinary authoritative commit path
is the only way in. §24A's rule is enforced by the shape of the type rather than
by a caller remembering it.
**Request/job/asset persistence** — **does not exist.** `MediaRequest` and
`MediaResult` are contracts; there are no `media_jobs` or `media_assets` tables.
See §F/K04 for the reasoning and how it is reported.
### B.3 Nothing calls any of it
No turn, prompt, context section, health check or startup path touches
`app/media/`. Asserted structurally: `test_m10_no_media.py` parses the import
statements of `turns.py`, `context/builder.py`, `narrative/apply.py`,
`narrative/store.py`, `tree.py`, `head.py` and `memorybank.py` and requires that
none imports the media package.
### B.4 What the packet excludes, which is the more interesting half
Excluded: the raw transcript, **all imported knowledge**, memories, summaries,
and the state document's facts, relationships and threads.
The rule is *what the story established at this position*, not *everything the
narrator was told*. Excluding imported knowledge **as a class** rather than
filtering marked secrets is what makes §H hold for a secret nobody thought to
mark: there is no filter to forget to extend.
### B.5 One deliberate gap
`ambience` returns `{time_of_day: null, lighting: null, mood: null}`. The fields
are in the shape because a provider adapter should not have to branch on their
absence; they are empty because filling them would mean extending the `set_scene`
event, which is on the prompt path — a change to what the narrator is asked for,
which is not M10's to make. Stated here rather than left to be discovered.
---
## C. Authority analysis
```text
AUTHORITATIVE DERIVED
┌────────────────────────────────┐ ┌──────────────────────────┐
│ actions (the transcript) │ │ Scene Packet │
│ state_events / state_proposals │──►│ built on read │
│ narrative_state │ │ stored nowhere │
│ narrative_state_after (per pos)│ │ identity computed │
│ branches, head_branch/depth │ │ │
│ checkpoints │ │ (future: media assets) │
└────────────────────────────────┘ └──────────────────────────┘
▲ │
└────────── NO PATH ◄──────────┘
visual_profiles ── presentation metadata, campaign-scoped.
Written by the reader, read by the packet, never by the story.
```
### C.1 The reverse path does not exist, proved three ways
**By structure.** `test_m10_authority.py::test_the_media_package_imports_nothing_that_writes_state`
requires that no file under `app/media/` references `narrative.apply`,
`set_current`, `head.move_to` or `tree.place_action`. `narrative.model` and
`narrative_store.current` are reads and are used.
**By vocabulary.** `test_no_state_event_type_was_added_for_media` requires that
`events.ALLOWED` contains no name beginning `media` or containing `visual` or
`asset`. There is no way for the media layer to speak in the story's language,
so there is nothing for the validator to accept.
**By behaviour, which is the one that would catch a mistake nobody predicted.**
Every test in `test_m10_authority.py` records the authoritative document —
`narrative_state`, `head_branch_id`, `head_depth`, and the counts of state
events, proposals and actions — before and after a media operation, and requires
them to be **identical**:
| Operation | Result |
| --- | --- |
| Write a visual profile | authoritative document unchanged |
| Update Alice's profile to "blue coat" | unchanged, and `"blue coat"` appears in no fact and in no entity |
| Delete a profile | unchanged |
| Build five scene packets | unchanged |
| Build a packet with an explicit range | head does not move |
| A dummy provider returns "Alice in a red coat in a corridor" | unchanged; neither string is anywhere in the state |
| A provider raises `MediaProviderError` | unchanged; head does not advance |
| Packet derivation raises inside `build` | unchanged, and the campaign still plays |
| Delete every profile | campaign intact; packet still builds with `visual_profile: null` |
| A profile naming an entity that does not exist | inert; packet unaffected; play continues |
### C.2 The claim in the other direction
§35 and §37 of the contract — a depiction never becomes canon, and promoting a
visual detail into canon must be a deliberate act by the reader. M10 makes that
structural: there is no code path from a `MediaResult` to a state event, because
there is no code that consumes a `MediaResult` at all.
### C.3 Why a profile carries no branch coordinate
Every other derived record in the schema carries `(branch_id, depth)` because it
describes a *moment*. A profile describes none: a character does not change
appearance because the story forked. Per-position profiles would have been wrong
twice — a reader who diverged would lose their cast's appearance, which is the
opposite of the continuity a profile exists for, and a descriptor document would
land in every per-position snapshot (measured: 245 copies of the same 367 bytes
in a 120-turn campaign, §K).
---
## D. Schema and migration
**Tables added:** one.
```text
visual_profiles
id INTEGER PRIMARY KEY
adventure_id INTEGER NOT NULL FK adventures(id) ON DELETE CASCADE, indexed
entity_key VARCHAR(200) NOT NULL
descriptors JSON
features JSON
style_notes TEXT
created_at DATETIME
updated_at DATETIME
UNIQUE (adventure_id, entity_key) -- uq_visual_entity
INDEX ix_visual_profiles_adventure_id
```
**Columns added to existing tables:** none.
**Columns changed or dropped:** none.
**Story tables touched:** none.
**Migrations added: none.** `LATEST_VERSION` is **92**, exactly as M9 left it.
`create_all` builds a new table on every path — fresh install, existing database,
test setup — as it did for `memories`, `branches`, `checkpoints`, `summaries` and
the M7 knowledge tables, and it builds the index too, because the index is
declared on the column rather than in `__table_args__`. Migration 92's own
comment states this rule for the M7 tables; M10 follows it.
**Proof that none is needed**, and that the two paths converge
(`test_m10_bundle.py`, §15 group):
| Check | Result |
| --- | --- |
| An M9-era database (schema at 92, no `visual_profiles`, a campaign already in it) opened by this build | table present, empty, `ix_visual_profiles_adventure_id` present, version still 92 |
| The campaign that was already there | title, action text and `PRAGMA foreign_key_check` unchanged |
| Opening the same database three times | index set identical after each; no error; no accumulation |
| Fresh install vs upgraded M9 file | `sqlite_master` DDL for `visual_profiles` **identical**; index sets **identical** |
| `backup.create()` on the upgraded file | `integrity == "ok"`, `quick_check` ok, `foreign_key_check` empty, opens independently, carries the new table and the pre-M10 campaign, `user_version` 92 |
| A profile written after the upgrade, then backed up | present in the backup with its descriptors |
**A defect this found — see §M.1.** M10 first shipped migration 93 creating
`ix_visual_profiles_adventure`. Because `create_all` had already built
`ix_visual_profiles_adventure_id`, an *upgraded* database ended up with both and
a fresh install with one. The fresh-versus-upgraded comparison caught it; the
migration was removed rather than renamed, because the right number of
migrations here is zero.
---
## E. Bundle implications
**Did M9's v3 bundle change?** Yes — it gained one optional key:
```json
"visualProfiles": [
{"entityKey": "alice",
"descriptors": {"build": "tall", "hair": "short black", "clothing": "grey blazer"},
"features": ["tortoiseshell glasses"],
"styleNotes": "photographic, natural light",
"createdAt": "2026-09-07T…"}
]
```
**Did the format version change?** **No. It stays `ai-dnd-adventure-v3`.**
**Why.** M9 introduced a version because a v2 file with no prompt provenance was
ambiguous between "written before M9" and "written by M9 from a campaign that
had none". The test is therefore not "did the format gain a key" but *does
omission create ambiguity about what an older file could have recorded*. It does
not: a campaign with no visual profiles is the ordinary case — appearance is
something a reader adds, not something a campaign has by default — so an absent
key unambiguously means "none", exactly as `checkpoints` did before M4 and
`knowledge` before M7. Bumping to v4 for a key whose absence is unambiguous would
spend the mechanism M9 built and make it mean less next time.
**Legacy import behaviour.** Unchanged, and re-checked:
| File | Result |
| --- | --- |
| A v3 file with `visualProfiles` deleted (an M9-written file) | imports; campaign intact; zero profiles |
| v2, v1 | unchanged — M9's readers are untouched |
| A malformed profile in an otherwise good file | **dropped, campaign still imports.** A story that would not import because a description of somebody's coat is malformed would be the wrong trade |
| A profile for an entity that no longer exists | imported and inert |
**Round trip.** Export → import → export produces the same profiles, in the same
shape (`test_a_profile_survives_a_second_round_trip_unchanged`). The copy's
profiles are its own rows — editing the copy does not reach the original — and
the copy's scene packet is populated from them, which is the point of carrying
them at all. A neighbouring campaign's profiles do not travel. The planner
(`bundle.plan`, M9's before-anything-is-written checkpoint) checks profiles
there rather than partway through a write.
**Clean-directory round trip, with profiles, across two real processes.**
`test_profiles_reach_a_clean_data_directory_on_another_machine` follows M9's
shape — two directories, two databases, two server processes, nothing crossing
but the file, and machine B's database a file that never existed before, so its
migrations run from nothing. Machine A profiles Alice and the office and exports;
machine B imports and builds a scene packet whose `action_summary` matches,
whose Alice carries her descriptors, whose office carries its lighting, and whose
Roger is still `visual_profile: null`. Only the campaign id differs, which is
what a new machine's id space means.
This exists because M9's own `test_m9_clean_import.py` predates visual profiles
and carries none — it passes unchanged (§L), but it could not have caught a
profile that failed to cross. M10 added no cross-machine coupling: `entity_key`
is a key inside the campaign's own state document, which travels in the same
file, so unlike a branch number or a knowledge source id it needs no translation
on import.
---
## F. Acceptance matrix
| Test | Verdict | Evidence |
| --- | --- | --- |
| **K01 — Scene Snapshot Exists** | **PASS** *(and was already passing)* | `narrative_state["scene"]` since M5; normalized packet at `GET /api/adventures/{id}/scene-packet`. `test_m10_media_hooks.py` (contents, bounds, identity), `test_m10_lineage.py` (position correctness through every history operation and two process restarts). |
| **K02 — Visual Character Profile** | **PASS** | `visual_profiles` + four endpoints (list, read, write, delete). Optional is tested, not just stated: Roger is deliberately unprofiled and the packet reports `visual_profile: null` rather than an empty profile. Stability is tested per operation — Undo and divergence, Redo and Save Point restore, a genuine process restart, and a two-process move to a clean data directory. |
| **K03 — Visual Location Profile** | **PASS** | Same table and same code path — a location is an entity with a `type`. `the office` carries a profile; the packet's `location.visual_profile` returns it. |
| **K04 — Attach Media Asset to Scene** | **PASS on the deferred branch** | Media tables are deliberately deferred, so this is reported against the acceptance text's own second clause ("if media tables are deferred: architecture/types should demonstrate equivalent extension point"). A test registers a dummy provider, builds a packet, generates a fake PNG carrying the packet's `scene_id` as provenance, and shows the story model byte-for-byte unchanged. **It is not PASS on the first clause**, and a reviewer who requires physical media tables in v1 should read this as PARTIAL. |
**K04's exact status, stated plainly.** Physically implementing `media_jobs` and
`media_assets` now would mean designing a queue with no producer and no consumer,
whose shape would be decided by a provider nobody has chosen; the codebase
declined the same thing once already (M6's `derived_status`, commented "not a job
queue"). What is guaranteed instead is the part that would be expensive to
retrofit: a scene identity that is *derived* from campaign and position, so a
future asset can reference a scene without a scenes table existing to reference.
---
## G. History and lineage evidence
`test_m10_lineage.py` — 8 tests, all passing, including the brief's own §4
example and its §17 sequence, and two **genuine spawned-process restarts**
(reusing `test_process_restart.Server`, so the process really goes away).
| Operation | What was checked | Result |
| --- | --- | --- |
| Set a scene, continue | packet describes the scene's position, not the head's | pass |
| **Undo** | packet follows the state back; a scene set after the undone point is gone from it | pass |
| **Redo** | packet returns to the later scene, with the same `scene_id` it had before | pass |
| **Retry** | the take that is live decides the scene; the superseded take's scene does not leak | pass |
| **Divergence** | Path A's scene and Path B's scene are different packets with different ids; both retained; the abandoned one is not current | pass |
| **Save Point restore** | packet matches the position the Save Point names | pass |
| **Redo, and a Save Point restore** | the profile is untouched by either; the scene set after the Save Point is correctly gone from the packet while the profile remains — which is the difference between story state and presentation metadata | pass |
| **Restart (real process)** | the same position yields the same `scene_id` and the same packet contents in a new process | pass |
| **Restart after divergence (real process)** | the campaign reopens on the branch it was left on, and the packet is that branch's | pass |
The mechanism behind all of it is M5's, not M10's: the head move restores the
whole state document and the scene is part of it. What M10 adds is the test that
pins it for the media seam, plus the derived identity that makes the restart
comparison meaningful — a stored id would have been trivially stable and would
have proved nothing.
---
## H. Hidden-information evidence
The test uses a **hidden M7 knowledge source**, because that is the product's
real narrator-only mechanism, rather than an invented marker. Each check carries
a **positive control**, so a pass cannot be a campaign where the secret was never
established.
**The sentinel.** `ZARQUON-CONCEALED-OBSERVER-7731`, in a hidden Canon source
describing a concealed observer behind the office's north wall.
| Check | Result |
| --- | --- |
| **Control:** does the narrator actually receive it? | **Yes** — the sentinel appears in the assembled prompt for a turn about the north wall panelling |
| Does it reach the Scene Packet? | **No** — neither the sentinel nor "concealed observer" is anywhere in the packet |
| **Control:** does a *visible* reference source reach the narrator? | **Yes** — the handbook appears in the context report's used-knowledge list |
| Does that visible source reach the packet? | **No** — imported knowledge is excluded as a class, which is what makes the rule hold for a secret nobody thought to mark |
| Do memories and summaries reach the packet? | **No** — after eight turns and a summary pass, the recurring "printer incident" text is absent |
| Does something the *story* established reach the packet? | **Yes**, and it should — once a validated `set_scene` event puts the observer in the room, the observer is in the packet. It is no longer narrator-only knowledge; it is something that happened. The sentinel is still absent, because the story never said it. |
That last row is the reason the boundary is drawn where it is: a packet that hid
established story from a depiction would be hiding the story from itself.
---
## I. No-media operation
`test_m10_no_media.py` — 12 tests, all passing. §20's list, run in one campaign
with an empty provider registry:
```text
several story turns ✓ Undo ✓
state extraction ✓ Redo ✓
memory + summary activity ✓ Retry ✓
knowledge retrieval ✓ Save Point restore ✓
restart ✓ *
```
\* In this suite the restart is a fresh session reading what was written, not a
new process. The **genuine spawned-process restarts are in
`test_m10_lineage.py`** (§G), where they carry more weight: they run with
profiles written and packets built, and check the derived `scene_id` is the same
in a process that never saw the first one.
with **no** media warning, **no** media connection attempt, **no**
missing-provider error, and no media schema requirement reaching narration.
Also checked:
- **The prompt is unchanged.** No context section labelled `media`,
`scene_packet` or `visual_profile*` exists; the strings `visual_profile` and
`scene_id` appear nowhere in the assembled system or story prompt.
- **Ordinary play writes no media row.**
- **No media setting exists** — neither in the settings API response nor as a
column on `Settings`. A setting that exists is a setting that can be pointed at
a cloud by mistake.
- **The turn path cannot reach the media package**, checked by parsing imports
rather than by grepping text.
- The app serves, plays and passes health checks with `providers.registered() == {}`.
---
## J. Security and locality
**Outbound destinations added: none.** No module under `app/media/` references
`httpx`, `requests`, `urllib.request`, `socket`, `aiohttp` or `subprocess`, and
that is a test, not an inspection. No provider adapter ships, so there is nothing
to connect *to*; the registry is empty at import and stays empty.
**Endpoint architecture — stricter than narration.**
`providers.endpoint_rejection_reason(url)` applies `endpoints.rejection_reason`
first (the shared policy: every address the hostname resolves to must be
loopback, RFC1918, link-local, IPv6 ULA or CGNAT; known cloud inference hosts are
refused by name; the check is on the **resolved address**, so
`localhost.evil.example` does not pass) and **then requires loopback in
addition**. Verified:
| Endpoint | Verdict |
| --- | --- |
| `http://127.0.0.1:8188/` | allowed |
| `http://192.168.x.x:8188/` (trusted LAN — allowed for *narration*) | **refused** for media |
| `https://api.openai.com/v1`, `https://replicate.com`, `http://8.8.8.8:8188`, `http://example.com` | refused |
This is deliberately narrower than `SECURITY-THREAT-MODEL.md` §73 permits, and
§42A now records the discrepancy rather than leaving it to be found. The
reasoning: a picture of a scene carries the scene with it, and a GPU rendering
someone's campaign is a machine that person is sitting at.
**No TLS verification bypass** was introduced. There is no `verify=False`, no
`-k`, and no new HTTP client at all; `tlstrust.py` is untouched and
`test_tls_trust.py` passes.
**Filesystem/media exposure: none.** No asset is stored, no directory is served,
no path comes from a caller. M10 adds no file-serving route.
**CSP/CORS: unchanged.** No frontend file was modified, no new origin is
contacted, and `test_offline_assets.py` (which reads the built SPA and the CSP)
passes.
**Cloud dependency: none.** Nothing was installed to demonstrate an interface —
no ComfyUI, no diffusers, no Whisper, no Kokoro, no model download. The
`requirements.txt` diff is empty.
**One new disclosure boundary, and it is not a network one.** The Scene Packet is
the input a future provider would receive, so its contents are a disclosure
decision — covered in §H.
---
## K. Performance and storage
Measured with `backend/tools/m10_media_cost.py`, a 120-turn campaign played
through the real turn engine and the real state pipeline, with SQL statements
counted by `tools/dbmeter.py`.
```text
120 turns, 245 action rows
scene records M10 wrote
visual_profiles rows 2 one per profiled entity, written once
scene rows 0 M10 adds no scenes table
M5 per-position state snapshots 245 already there; the scene lives here
bytes added to the database
empty database 188416 B
after the fixture campaign 196608 B
after 120 more turns 1056768 B
visual profile content 367 B 0.035% of the database
profile duplication: campaign-scoped against per-position
as stored, once per entity 367 B
if snapshotted per position 89915 B x245
packet: persisted or constructed
rows written while building one 0
packet rows in any table 0 built on read, never stored
build time, 2 turns 16.6 ms
build time, 122 turns 12.6 ms
current-scene query behaviour
statements, 2 turns 5
statements, 122 turns 4
does not grow with the campaign
```
The packet's four statements at turn 122 are: the adventure, its visual
profiles, the current user, and the head's branch. **Nothing walks the
transcript**, which is the property that matters — a scene derivation that
scanned actions would have made every future depiction O(turns).
The `x245` row is the measurement behind §C.3: per-position profiles would have
stored the same 367 bytes 245 times in this campaign to say something that never
varies.
**Storage added to an ordinary campaign that uses no profiles: one empty table.**
---
## L. Regression counts
All runs on this branch, after every M10 change, on 2026-09-07.
| Suite | Result | Time |
| --- | --- | --- |
| **Backend, full** | **1,191 passed, 14 skipped, 0 failed** — 1,205 collected, exit 0 | 862.9 s |
| **M10-specific** | **89 passed** (39 hooks + 8 lineage + 12 authority + 12 no-media + 18 bundle/migration) | 56.4 s |
| **Frontend component suite** | **145 passed**, 12 files, 0 failed | 8.17 s |
| **Lint** (`oxlint`) | **0 errors**, 15 warnings, exit 0 | — |
| **Production build** (`vite build`) | clean — `index-DcHbz7ga.js` 389.66 kB (gzip 119.25 kB), `index-B55Q8MSM.css` 47.50 kB | 0.58 s |
| **Docker** (`--no-cache`) | **exit 0**; image runs and imports `app.media` with `registered() == {}` | 25.2 s |
| **Browser** | **not run — M10 adds no reader-facing surface.** See §L.2 |
The 14 skips are M9's and are unchanged — confirmed by running the five files
that carry a skip condition on their own (26 passed, 14 skipped): seven need a
second machine or an environment the suite cannot create; the rest need a real
local model.
The 15 lint warnings are pre-existing (`no-unused-vars` in two test files, and
`react/only-export-components` in components that export a constant beside a
component). **M10 modified no frontend file**, so the count is M9's, unchanged.
### L.1 By group
Each group was run on its own, so a reviewer can check a claim without running
the whole suite. Every group is green.
| Group | Files | Result |
| --- | --- | --- |
| **M10** | `test_m10_media_hooks` (39), `_lineage` (8), `_authority` (12), `_no_media` (12), `_bundle` (18) | **89 passed** — 56.4 s |
| **M9 recovery** | `test_m9_backup`, `_clean_import`, `_corrupt_bundles`, `_legacy_bundles`, `_portability`, `test_bundle_v2` | **173 passed** — 371.3 s |
| **Migration** | `test_tree_migration`, `test_knowledge_migration`, `test_pre_m5_compatibility`, `test_snapshot_compression` | **50 passed** — 27.8 s |
| **M5/M6/M7 state, memory, knowledge** | `test_narrative_state`, `test_worldstate_integration`, `test_memory_nodes`, `test_memory_retrieval`, `test_context_memory`, `test_imported_knowledge`, `test_knowledge_retrieval_quality` | **216 passed** — 113.7 s |
| **M3/M4 history** | `test_story_tree_baseline`, `test_branch_forking`, `test_head_cursor`, `test_save_points`, `test_take_state`, `test_process_restart` | **137 passed** — 139.6 s |
| **Security / offline** | `test_egress`, `test_endpoint_policy`, `test_local_only_surface`, `test_offline_assets`, `test_tls_trust` | **95 passed** — 18.4 s |
The security group is the one that carries §J's claims: no outbound route, the
endpoint policy, the local-only API surface, the offline asset and CSP checks,
and the TLS trust union. All were green before M10 and are green now.
### L.2 On the browser run
M10 adds **no reader-facing surface**: no page, no control, no copy, no route in
the SPA. `git status` shows no file under `frontend/` modified. The five new
endpoints are backend-only and nothing in the browser calls them. A real-browser
regression pass would therefore be re-verifying M8/M9's surfaces against a build
identical to theirs, and its evidence would be M9's evidence. The frontend
component suite and the production build were run anyway, and are green.
This is stated as a decision, not an omission: **if the reviewer wants a browser
pass as a matter of process, it has not been done.**
---
## M. Findings
### M.1 A redundant index migration made two databases disagree — **introduced by M10, fixed here**
**Severity:** low in effect, moderate in kind. **Blocker:** no — fixed.
**Owner:** M10 (closed).
M10 first added migration 93, `CREATE INDEX IF NOT EXISTS ix_visual_profiles_adventure
ON visual_profiles (adventure_id)`. But `VisualProfile.adventure_id` declares
`index=True`, so `create_all` already builds `ix_visual_profiles_adventure_id` —
on a fresh install *and* on an existing database, since `create_all` runs before
the migration loop. The result:
```text
upgraded from 92: ix_visual_profiles_adventure, ix_visual_profiles_adventure_id
fresh install: ix_visual_profiles_adventure_id
```
Two schemas differing by which path the file took, which is the thing a migration
exists to prevent, plus a redundant index on every upgraded database.
**Found by** `test_a_fresh_database_arrives_at_the_same_place`, which compares a
fresh schema against an upgraded one. Neither database examined on its own would
have shown it. **Fixed by removing the migration**, not by renaming the index:
migration 92's own comment already records the rule for the M7 tables — when the
index is declared on the column there is nothing left for a `CREATE INDEX` to do.
`LATEST_VERSION` returns to 92.
### M.2 The state model refuses an over-large scene, and a test asked for one — **not a defect**
While writing the packet's bounds test I sent 43 entries in `set_scene`'s
`present`, exceeding `validate.MAX_LABELS = 40`. The event was correctly refused
and the previous scene stayed, so the test measured the wrong scene and failed.
Recorded because the diagnosis matters: the product was right and the test was
wrong. The test now uses 33 and asserts its own precondition, so it cannot
silently measure a scene it did not set.
### M.3 The Story Engine vocabulary grep hit the file that forbids the vocabulary — **test defect, fixed**
§9's rule is that no provider vocabulary (ComfyUI, Whisper, `num_inference_steps`,
LoRA) appears in the Story Engine. The first version of the test grepped the
whole backend and hit `providers.py`, whose docstrings *name* those things
precisely in order to exclude them. Fixed by scoping the grep to the story
engine, and by adding a complementary test that checks the seam **by behaviour**:
no provider registered, and no networking import anywhere under `app/media/`.
### M.3a A text search for "media" matched "im**media**tely" — **test defect, fixed**
The first version of the no-media import check read each turn-path module and
required the string `media` to be absent. `narrative/store.py` contains the word
*immediately*, so the test failed on a module that imports nothing. It now parses
the file and inspects its **import statements**, which is what the claim was
always about. Recorded because the failure looked briefly like a real coupling
and was not, and because the fixed version is the stronger test: a module could
have imported the package while never spelling the word in prose.
### M.4 `ambience` is present and empty — **known gap, deliberate**
**Severity:** low. **Blocker:** no. **Owner:** whichever milestone builds a
coordinator.
The packet's `ambience` object has the right shape and no content, because
filling it would mean extending the `set_scene` event — a change on the prompt
path, asking the narrator for something new, which is outside M10's scope. A
future provider gets a stable shape today and content when someone decides the
narrator should be asked.
### M.5 Deleting profiles is not recoverable from within the app — **known limit, stated**
**Severity:** low. **Blocker:** no.
M9's rebuildable data can be regenerated; a visual profile cannot, because it is
something a reader wrote. It travels in the bundle, so a backup or an export
recovers it, and deletion is per-entity and explicit. There is no undo for it,
and none was invented — that would be a second history model beside the story's.
**No pre-existing defect was found in M9's or earlier work during this
milestone.** The full backend suite was green before M10 began and is green now.
---
## N. Planning changes
| Document | Change | Why |
| --- | --- | --- |
| `planning/DATA-MODEL.md` | **New §28A** — media extension points as implemented | §20/§27/§28 describe a scene table and job/asset tables. Only one of the three exists, and a reader of the conceptual model needs to know which, and why the scene is derived from §20's own data rather than stored beside it. |
| `planning/TECHNICAL-DESIGN.md` | **New §15.1** under Scene and Future Media Boundary | §15 said "persist or derive". The answer is *derive*, and the reason (M5 already persisted it) is the milestone's central fact. Also records the packet's exclusions, the STT asymmetry and the endpoint policy. |
| `planning/MEDIA-EXTENSION-CONTRACT.md` | **New §90**, appended | The contract is Phase 0B design and stays readable as such. §90 records what was built, the three places implementation answered an open question (§5 already satisfied, §12 drawn wider, §7-9 collapsed into one table), and what is deliberately unbuilt. |
| `planning/BUILD-MILESTONES.md` | **M10 status block** | Milestone status, the shaping finding, what shipped, and the defect its own tests caught. The four post-M8 playtest findings above it are untouched and still M11's. |
| `planning/V1-ACCEPTANCE-TESTS.md` | **K01-K04 results** | Acceptance evidence. K01 records that it was already passing; K04 records which of its two clauses it passes on. |
| `planning/SECURITY-THREAT-MODEL.md` | **New §42A** | The trust boundary did not widen, but in one place the implementation is deliberately **narrower** than §73 permits. A stricter implementation than the model describes is still a discrepancy, and an undocumented one becomes an accidental relaxation later. |
| `planning/VERSION.md` | **v3.6 entry** | Records this milestone's documentation changes and the two decisions (no format bump, no migration). |
| `planning/README.md` | Status, milestone map, reading order, report rotation | M10 is implemented; M11 is next. Records **why M9's report stays in `reports/`** against the usual rotation: M9 is not accepted, and M10's baseline is M9's. |
| `README.md` | `media/` in the architecture map; `VisualProfile` in the model list; test count 920 → 1,191 | The map is the first thing a new reader reads. |
| `DEVELOPMENT.md` | The stricter future media endpoint rule; a note that the suite takes ~15 minutes | Whoever adds the first provider should find the rule before writing the adapter. |
No planning document was rewritten, and no earlier milestone's evidence was
edited.
---
## O. M11 handoff
### O.1 M10's residual risk
1. **K04 is satisfied structurally, not physically.** No `media_jobs` or
`media_assets` table exists. A future coordinator will design them, and the
contracts here constrain that design only loosely. *Risk: low — the expensive
part (a stable scene identity) is fixed; the cheap part (two tables) is not.*
2. **`ambience` is an empty shape** (§M.4). Filling it means extending
`set_scene`, which changes what the narrator is asked for. *Risk: low; a
provider adapter written today would find the fields and no values.*
3. **The seam has no consumer, so it is unexercised by real use.** Every test
here uses a dummy provider. The contracts are shaped by the contract document
and by what the state model can supply, not by an adapter that had to work
against a real generator. *Risk: moderate for the interfaces' ergonomics, nil
for the story engine — the first real adapter may want the packet reshaped,
and nothing in the story depends on its shape.*
4. **A visual profile cannot be recovered from within the app** (§M.5).
5. **Profiles are not surfaced to the reader at all.** They are API-only. Whoever
builds a media UI owns the browser surface, and no reader-facing vocabulary
for them has been invented — deliberately, since M10 was told not to introduce
reader-facing branding or surfaces.
### O.2 M9 carry-forward still relevant
All six of M9's residual risks are unchanged by M10 — none was addressed and none
was made worse:
| M9 residual | Status after M10 |
| --- | --- |
| Bundle ceiling ~279 turns | unchanged. M10 adds ~370 bytes per campaign to a file whose ceiling is set by per-position state; it does not move the number. |
| `quick_check` rather than `integrity_check` | unchanged; re-exercised on a migrated database (§D) |
| No scheduled backup | unchanged |
| Stale `chunk_id` in a restored snapshot | unchanged |
| Importing machine's context window may differ | unchanged; still M11's |
| This machine cannot drive a file into/out of the browser | unchanged; still M11's, and still the cheapest fix is an unconfined Firefox or Xvfb |
M9's two items "carried to M10" are both **closed**: media tables were owned and
the decision is recorded (§F, K04); the `scene` section of the state document was
indeed the extension point, and is what the packet is built from.
### O.3 The post-M8 hands-on findings — still M11's, untouched
M10 neither implemented nor tested any of them, as its brief required. They are
listed here so they cannot be lost when M9's report is eventually archived; the
durable copy is in `BUILD-MILESTONES.md`.
| Finding | Owner |
| --- | --- |
| **A.** The browser tab still reads `AI D&D` | M11 release polish |
| **B.** After Undo, the reader cannot tell where they are | M11 UX/release polish |
| **C.** The narration-length setting has no measurable effect | M11 realistic-model behaviour |
| **D.** Character identity / coreference confusion — root cause **unknown**, and the playtest database was destroyed | M11 realistic-model / context diagnostic |
**On D specifically:** M10's fixture is deliberately the same shape — a
protagonist, two more characters in the room, and a fourth who is not — but
**nothing in M10 asserts anything about coreference**, and the fixture's
resemblance is not evidence about the finding. M11 still owes the explicit
diagnostic. What M10 does contribute, incidentally, is that a visual profile
attaches to the canonical `entity_key` rather than to a display name, so whatever
M11 concludes about identity, profiles are keyed to the thing the state model
considers one character.
### O.4 The context-window issue
Unchanged and still M11's: a deployment's enforced context window may be far
below `Settings.context_token_budget` (Ollama defaults to 4,096 when it sees no
VRAM). Documented in `DEVELOPMENT.md`; detection, the Settings warning question,
and the 100-turn certification all remain open. M10 sends nothing to a model and
does not touch it.
### O.5 Realistic-model / 100-turn work
Untouched by M10, and M10 adds no new realistic-model obligation: the media seam
has no model in it. The 100-turn certification, the contrast/focus measurement,
the WCAG audit and the streaming-import question all carry forward unchanged.
---
## P. Final verdict
**1. Is M10's Definition of Done satisfied?**
> *Future media providers can be added through defined local interfaces without
> redesigning core story authority/history.*
**Yes.** A provider is added by implementing a `Protocol` and registering it;
it receives a Scene Packet built from the authoritative state at a position, and
returns a `MediaResult`. Nothing in story authority or history changes to
accommodate it — proved by the fact that nothing in `app/media/` can even import
the code that writes state, and that every media operation leaves the
authoritative document byte-identical.
The honest qualification: **no real adapter has been written against these
interfaces**, so their ergonomics are untested (§O.1.3). The story engine's
independence, which is what the Definition of Done is actually about, is tested.
**2. Are K01-K03 all PASS?** **Yes** — all three, with evidence in §F and §G.
K01 additionally records that it was already passing before M10 began.
**3. What is K04's exact status?** **PASS on the acceptance text's deferred
branch** ("if media tables are deferred: architecture/types should demonstrate
equivalent extension point"), demonstrated by a dummy provider producing an asset
against a real packet with the story model unchanged. **Not PASS on the first
branch**, since no media table is physically implemented. A reviewer who requires
physical tables in v1 should read K04 as **PARTIAL**; the decision and its
reasoning are in §F.
**4. Can all ordinary story operation run with zero media provider?** **Yes** —
§I. Turns, state extraction, memory, summaries, knowledge retrieval, Undo, Redo,
Retry and Save Point restore, with an empty registry, no media setting in
existence, no warning, no connection attempt and no media row written. Restart is
covered as a genuine spawned-process restart in the lineage suite (§G); the
no-media suite's own restart step is a fresh session read, and §I says so.
**5. Can a future image provider be added without modifying story authority or
history?** **Yes.** It implements `MediaProvider`, is handed a packet, and
returns a result. No story table, event type, or history operation changes.
**6. Can a future video provider consume a multi-turn scene representation
without redesigning history?** **Yes.** `packet.build(..., start=, end=)` takes a
depth range on the branch and the resulting `scene_id` encodes it
(`c7:b3:4-9`). The range is expressed in the coordinates history already uses, so
a multi-turn packet is a read of existing structure rather than a new one.
**7. Does future STT feed editable draft input rather than authoritative state?**
**Yes, structurally.** `TranscriptionProvider.transcribe` returns a
`DraftTranscription` with `editable=True` and no commit method. A transcriber
cannot submit; the ordinary authoritative path is the only way in.
**8. Is scene/media data branch-safe?** **Yes** — §G. The scene follows the
active lineage through Undo, Redo, Retry, divergence, Save Point restore and two
genuine process restarts, because it *is* the authoritative state rather than a
copy of it. Profiles are campaign-scoped by design and are stable across all of
those, which is the correct behaviour for appearance and is argued in §C.3.
**9. Did M10 create any network dependency?** **No.** No dependency added, no
HTTP client, no socket, no subprocess, no provider adapter, no model download, no
media endpoint setting. The endpoint *policy* that a future provider will meet is
stricter than the one narration uses: loopback only.
**10. Is there any blocker before M11?** **No blocker.** Two things a reviewer
should decide rather than inherit:
- whether **K04 on the deferred branch** is acceptable for v1, or whether media
tables must be physically present (§F);
- whether **no browser regression pass** is acceptable given that M10 modified no
frontend file (§L.2).
Neither is a defect; both are decisions that belong to the reviewer.