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
343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""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
|
|
)
|