M10: the seam for media, and no media
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:
co-authored by
Claude Opus 5
parent
44edece67e
commit
1013c94eb1
@@ -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()
|
||||
Reference in New Issue
Block a user