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
+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],