Files
JesseMarkowitzandClaude Opus 5 44edece67e M9: a campaign you can actually get back
A campaign could already be exported and imported. What could not survive the
trip was everything that explains it: the state events behind the authoritative
document, the prompt each turn was actually given, the passages it was shown,
the summaries that carry long-story continuity, and which take belonged to which
turn. An imported campaign could be read and could no longer say why it was what
it was — and a manual correction, the one state change no narration explains,
was indistinguishable from something the story had established.

The bundle is now `ai-dnd-adventure-v3`, and the version is the design rather
than a side effect. Everything added here could have been another optional key,
the way persona, Save Points, narrative state and imported knowledge each were.
That mechanism stops working at exactly this addition: a v2 file with no prompt
provenance is ambiguous between "written before M9" and "written by M9 from a
campaign that has none", and those are different facts about a campaign. A
version number is how a recovery file states what it was capable of recording.
v1 and v2 still import, and every seam from pre-active-head onward is tested for
the rule that an older file is never reinterpreted under a newer assumption.

Two categories became three. "Chosen travels, derived is recomputed" was enough
until stored prompts had to be decided: they are derived, and they must travel
anyway. The test that separates evidence from cache is not "could this be
recomputed" but "would a recomputation answer the same question" — a rebuilt
search index answers the same question, a rebuilt prompt says what the turn
would be told *now*, which is the opposite of what the inspector is for.

Also here: a real SQLite backup, through the online backup API rather than a
file copy, taken while the application is running and verified before it is
kept; story cards settled as compatibility-only legacy data and taken out of the
narrator's prompt, because they were the untracked path around knowledge
authority that IMPORTED-KNOWLEDGE-DESIGN §73 already forbade; and no schema
change at all, proved against a database M8's own code wrote.

Three defects, found by running the milestone's own tests rather than by reading
them. Deleting a campaign leaked its FTS index rows, and SQLite then handed the
freed ids to the next source imported into any campaign, which failed with an
integrity error that Reindex could not repair — both ends are closed, and a
database already carrying the damage now repairs itself. An imported node with
no state snapshot was being stamped with the campaign's head state, so an Undo
to turn 2 showed what the story knew at turn 20. And the snapshot relink did not
persist at all, because it mutated a dict in place on a column SQLAlchemy tracks
by assignment: it looked correct in memory and wrote the wrong ids to disk.

Carrying per-turn prompts looked like it would halve the length of campaign that
can be restored. Measured — and after compressing them inside the file —
everything M9 added costs 12% of it: the import ceiling moves from about 318
turns to about 279, against a 100-turn certification target. The dominant cost
is not M9's at all. The per-position narrative state document is 74% of a
bundle, and v2 already carried it.

Backend 1,102 passed / 14 skipped / 0 failed. Frontend 145 passed. Lint,
production build and Docker build clean. Verified across two server processes
with two data directories, and in a real browser against a real narrator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
2026-09-07 01:55:45 -04:00

1187 lines
50 KiB
Python

"""M9: a campaign survives being moved, and can say why it is what it is.
The Definition of Done is one sentence — *a campaign can be safely exported,
imported into a clean data directory, and reopened at the exact intended active
position with authoritative history/state intact* — and this file is the part of
its evidence that runs in the suite. Two things it deliberately does not claim:
* **"A clean data directory" is not proved here.** Every test in this file runs
against one database, and importing beside the original is a weaker check than
importing where the original has never existed — a shared row, a shared id
space or a shared cache would go unnoticed. `test_m9_clean_import.py` does that
across a genuine second process and a genuinely empty database, and this file
says so rather than implying otherwise.
* **A green run here is not the milestone.** The browser workflow, the process
restart and the real-narrator run are in their own places, for the reason M8
wrote down: a suite that stubs a boundary is not evidence about that boundary.
What this file does prove is the shape of the contract. `m9_fixture` builds one
campaign that holds every portable data family at once — an undone head, a
retained future, an abandoned line with its own state and derived data, two
takes at one coordinate, Save Points on two branches, a manual state correction,
five imported sources across three classes and both lifecycle states, and stored
prompts for turns that used them. Every assertion below is a comparison between
that campaign and its copy, read through the API a reader reads.
python -m pytest tests/test_m9_portability.py -v
"""
import asyncio
import copy
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, bundle, limits, memorybank, models, summaries
from app.context import lineage
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.routers import adventures
import m9_fixture
from fakes import ScriptedProvider
class StubDerived:
"""A deterministic embedder and summariser in one object.
Both factories are stubbed from it, which is M6's finding M6-F3: replacing
only one leaves the other building a real provider against the default
endpoint, and every turn in the file opens a socket.
"""
written = 0
async def complete(self, system, prompt, **kwargs):
StubDerived.written += 1
return f"Memory {StubDerived.written}: what the story had established."
async def embed(self, texts):
out = []
for text in texts:
lowered = text.lower()
out.append([
1.0,
1.0 if "abbey" in lowered or "crypt" in lowered else 0.0,
1.0 if "tavern" in lowered or "lantern" in lowered else 0.0,
1.0 if "rain" in lowered else 0.0,
1.0 if "seal" 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()
StubDerived.written = 0
setup = SessionLocal()
user = models.User(is_guest=False, email="m9@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="stub-embed",
context_token_budget=4000, max_output_tokens=400, memory_top_k=3,
))
adventure = models.Adventure(
user_id=user.id, title="M9 Portability Fixture",
campaign_canon=m9_fixture.CAMPAIGN_CANON,
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text=m9_fixture.OPENING,
))
# A neighbour campaign. A bundle that reached past its own rows would bring
# this one's story back with it, and every count below would still add up.
neighbour = models.Adventure(user_id=user.id, title="Neighbour")
setup.add(neighbour)
setup.flush()
setup.add(models.Action(
adventure_id=neighbour.id, type="start",
text="A different story entirely, which must not travel.",
))
setup.commit()
adv_id, other_id, user_id = adventure.id, neighbour.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
test_client.other_id = other_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)
# ----------------------------------------------------------------- helpers
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:
"""Imports a bundle and returns the new campaign's id."""
response = client.post("/api/adventures/import", json=payload)
assert response.status_code == 201, response.text[:600]
return response.json()["id"]
def refuses(client, payload) -> str:
"""Imports a bundle expecting a refusal, and returns the reason given."""
before = _campaign_count()
response = client.post("/api/adventures/import", json=payload)
assert response.status_code in (400, 409), (
f"expected a refusal, got {response.status_code}: {response.text[:300]}"
)
assert _campaign_count() == before, "and nothing was written"
return response.json()["detail"]
def _campaign_count() -> int:
with SessionLocal() as db:
return db.query(models.Adventure).count()
def rows(adv_id, model, **filters):
with SessionLocal() as db:
query = db.query(model).filter(model.adventure_id == adv_id)
for field, value in filters.items():
query = query.filter(getattr(model, field) == value)
return query.order_by(model.id).all()
@pytest.fixture()
def moved(client):
"""The fixture campaign, its bundle, and the copy the bundle produced.
Built once per test that needs it, because building it is a second of
scripted turns and most of the assertions below are about the same round
trip seen from different angles.
"""
source = m9_fixture.build(client, client.adv_id)
payload = export(client)
copy_id = bring_back(client, payload)
return {
"source": source,
"bundle": payload,
"copy_id": copy_id,
"copy": m9_fixture.snapshot_of(client, copy_id),
}
# --------------------------------------------------- the format and its versions
def test_the_export_declares_version_three(moved):
assert moved["bundle"]["format"] == "ai-dnd-adventure-v3"
def test_every_earlier_version_still_imports(client):
"""A backup that stops importing is not a backup.
Both older versions are exercised, and each is checked for the thing its own
format could carry: a v1 file has a story, and a v2 file has a story, a tree
and a chosen head.
"""
m9_fixture.build(client, client.adv_id)
v3 = export(client)
v2 = _as_version_two(v3)
copy_id = bring_back(client, v2)
assert _texts(client, copy_id) == _texts(client, client.adv_id)
v1 = {
"format": bundle.LEGACY_FORMAT,
"title": "An old file",
"actions": [
{"index": 0, "type": "start", "text": "It begins."},
{"index": 1, "type": "do", "text": "look around"},
{"index": 2, "type": "ai", "text": "Nothing moves."},
],
}
v1_id = bring_back(client, v1)
assert _texts(client, v1_id) == ["It begins.", "look around", "Nothing moves."]
def test_a_version_two_file_gets_no_invented_evidence(client):
"""Absent is read as "the format could not say", never as "there was none".
The distinction is the whole reason the version was bumped. A v2 file
carries no state events, and the import must leave the campaign with none
rather than reconstructing an audit trail from the snapshots it does carry —
a reconstructed trail would be this build's reading of a history it never
saw, presented to a reader as the record of what happened.
"""
m9_fixture.build(client, client.adv_id)
copy_id = bring_back(client, _as_version_two(export(client)))
assert rows(copy_id, models.StateEvent) == []
assert rows(copy_id, models.StateProposal) == []
assert rows(copy_id, models.Summary) == []
assert all(
row.context_snapshot is None
for row in _all_actions(copy_id)
), "a v2 file carries no prompts, and none may be invented"
# What v2 *could* say still arrives in full.
assert _texts(client, copy_id) == _texts(client, client.adv_id)
assert len(rows(copy_id, models.Checkpoint)) == 2
def test_a_format_from_a_later_build_is_refused_rather_than_guessed_at(client):
detail = refuses(client, dict(export(client), format="ai-dnd-adventure-v9"))
assert "ai-dnd-adventure-v9" in detail
assert bundle.FORMAT in detail
def _as_version_two(payload: dict) -> dict:
"""The same campaign as a version 2 file: no v3 sections, no v3 node keys.
This is what an export taken before M9 looks like, built by removing exactly
what M9 added rather than by keeping a fixture file that would drift.
"""
older = copy.deepcopy(payload)
older["format"] = bundle.TREE_FORMAT
for key in ("stateEvents", "stateProposals", "summaries"):
older.pop(key, None)
for action in older["actions"]:
for key in ("contextSnapshot", "contextSnapshotZ", "id", "parentId"):
action.pop(key, None)
for memory in older.get("memories") or []:
memory.pop("authority", None)
for source in older.get("knowledge") or []:
for key in ("sourceId", "parserVersion", "chunkingVersion"):
source.pop(key, None)
return older
# ------------------------------------------------------------------ I01, I02
def test_i01_the_export_holds_enough_to_restore_the_story(moved):
"""I01. Not "the file is non-empty": the file names every family."""
payload = moved["bundle"]
for section in ("actions", "branches", "checkpoints", "knowledge",
"memories", "summaries", "stateEvents", "stateProposals"):
assert payload[section], f"the bundle carries no {section}"
assert payload["headDepth"] is not None
assert payload["narrativeState"]
assert any(m9_fixture.snapshot_in(a) for a in payload["actions"])
def test_i02_the_copy_reads_the_same_story_and_holds_the_same_state(moved):
"""I02. The active transcript and the authoritative state, both restored."""
assert moved["copy"]["transcript"] == moved["source"]["transcript"]
assert moved["copy"]["state"] == moved["source"]["state"]
assert moved["copy"]["state"], "and the state is not empty in both"
assert moved["copy"]["canon_rules"] == moved["source"]["canon_rules"]
def test_the_neighbouring_campaign_does_not_travel(moved, client):
"""A bundle carries one campaign. Nothing from the one beside it."""
everything = _texts(client, moved["copy_id"], every_branch=True)
assert not any("A different story entirely" in text for text in everything)
# ------------------------------------------------------- I07 and the head rules
def test_i07_the_copy_opens_at_the_exported_head_not_at_the_tip(moved, client):
"""I07. The head is where the reader left it, and the future is still there.
The fixture's head is behind the retained tip of its own branch *and* behind
the abandoned line's, and the fixture ends with an Undo precisely so that
the newest row written is not the position being read. An importer that
took the tip, the newest row, or the deepest row would each land somewhere
else.
"""
source_head = _head(client.adv_id)
copy_head = _head(moved["copy_id"])
assert copy_head["depth"] == source_head["depth"]
assert moved["copy"]["transcript"] == moved["source"]["transcript"]
# The future past the head is still in the database, unread.
ahead = [
row for row in _all_actions(moved["copy_id"])
if row.branch_id == copy_head["branch_id"] and row.depth > copy_head["depth"]
]
assert ahead, "the retained future did not survive the round trip"
# And Redo is offered, rather than the story having silently been redone.
assert moved["copy"]["can_redo"] is True
assert moved["source"]["can_redo"] is True
def test_redo_after_import_walks_the_future_that_was_retained(moved, client):
"""Redo is coherent in the copy: it reaches the same next turn."""
before = client.post(f"/api/adventures/{moved['copy_id']}/redo")
assert before.status_code == 200, before.text[:300]
original = client.post(f"/api/adventures/{client.adv_id}/redo")
assert original.status_code == 200, original.text[:300]
assert _texts(client, moved["copy_id"]) == _texts(client, client.adv_id)
def test_a_file_written_before_the_head_was_carried_opens_at_its_tip(client):
"""I07's compatibility clause, and why it is not a degraded path.
Such a file was written when the head could not be anywhere but the tip, so
opening it there reproduces the position it actually recorded. Guessing some
other position for it would be the failure.
"""
m9_fixture.build(client, client.adv_id)
payload = export(client)
stated = payload.pop("headDepth")
copy_id = bring_back(client, payload)
landed = _head(copy_id)
tip = max(
row.depth for row in _all_actions(copy_id)
if row.branch_id == landed["branch_id"]
)
assert landed["depth"] == tip
assert landed["depth"] > stated, "and the fixture's head really was behind it"
def test_a_head_beyond_the_story_the_file_carries_is_refused(client):
"""A file disagreeing with itself is refused, not opened at a guess."""
m9_fixture.build(client, client.adv_id)
payload = export(client)
detail = refuses(client, dict(payload, headDepth=payload["headDepth"] + 500))
assert "ends at" in detail
# ------------------------------------------------------------- I03, takes
def test_i03_both_futures_survive_and_stay_distinguishable(moved, client):
"""I03. The abandoned line comes back, still marked as abandoned."""
assert moved["copy"]["branch_count"] == moved["source"]["branch_count"] == 2
stories = _texts(client, moved["copy_id"], every_branch=True)
assert any("The crypt is dry" in text for text in stories), \
"the abandoned future is gone"
assert any("windows are lit" in text for text in stories), \
"the continuation the reader chose is gone"
# Disposition, not merely presence: the line the story left is still the
# line the story left, at the depth it left it.
with SessionLocal() as db:
branches = (
db.query(models.Branch)
.filter(models.Branch.adventure_id == moved["copy_id"])
.order_by(models.Branch.id).all()
)
superseded = [b for b in branches if b.superseded_at is not None]
assert len(superseded) == 1
assert superseded[0].superseded_depth == 6
def test_a_superseded_take_survives_and_the_selected_one_is_still_selected(moved):
"""No accepted row disappears for being inactive."""
source_takes = _takes(moved["source"]["id"])
copy_takes = _takes(moved["copy_id"])
assert copy_takes == source_takes
assert sum(1 for _, live in copy_takes if not live) == 1, \
"the retained alternate take is gone"
assert sum(1 for _, live in copy_takes if live) == 1
def test_the_takes_at_one_turn_are_still_grouped_as_one_turn(moved, client):
"""M9's parentage. The pager reads the same in the copy as in the original.
Version 2 exported no parentage, so every imported node landed parentless
and `attempts.group` fell back to the coordinate. That is right for a simple
retry and wrong as soon as two takes of one turn each have takes of their
own beneath them.
"""
assert _take_pagers(client, moved["copy_id"]) == \
_take_pagers(client, client.adv_id)
with SessionLocal() as db:
parented = (
db.query(models.Action)
.filter(models.Action.adventure_id == moved["copy_id"],
models.Action.parent_id.isnot(None))
.count()
)
assert parented > 0, "no imported node knows which turn it belongs to"
# ------------------------------------------------------------------------ I04
def test_i04_save_points_survive_with_their_names_notes_and_positions(moved):
assert moved["copy"]["checkpoints"] == moved["source"]["checkpoints"]
assert len(moved["copy"]["checkpoints"]) == 2
def test_each_restored_save_point_reaches_the_position_it_names(moved, client):
"""And restoring one uses M3's head movement, leaving later history alone."""
copy_id = moved["copy_id"]
before = len(_all_actions(copy_id))
for point in client.get(f"/api/adventures/{copy_id}/checkpoints").json():
assert point["resolved"] is True, f"{point['name']} resolves to nothing"
restored = client.post(
f"/api/adventures/{copy_id}/checkpoints/{point['id']}/restore"
)
assert restored.status_code == 200, restored.text[:300]
assert _head(copy_id)["depth"] == point["depth"]
assert len(_all_actions(copy_id)) == before, "restoring deleted history"
def test_the_two_save_points_restore_to_different_states(moved, client):
"""They name different positions, so they must restore different states."""
copy_id = moved["copy_id"]
seen = []
for point in client.get(f"/api/adventures/{copy_id}/checkpoints").json():
client.post(f"/api/adventures/{copy_id}/checkpoints/{point['id']}/restore")
seen.append(client.get(f"/api/adventures/{copy_id}/state").json()["document"])
assert seen[0] != seen[1]
def test_a_save_point_naming_a_position_the_file_does_not_hold_is_dropped(client):
"""Deliberate, documented, and never retargeted somewhere else.
A bad bookmark costs the bookmark. Refusing the campaign over it would lose
the story to save the bookmark, and moving it to a nearby turn would be an
invention — the reader named a position, and if that position is not in the
file then no other position is the one they named.
"""
m9_fixture.build(client, client.adv_id)
payload = export(client)
payload["checkpoints"][0]["depth"] = 9999
copy_id = bring_back(client, payload)
names = [c["name"] for c in
client.get(f"/api/adventures/{copy_id}/checkpoints").json()]
assert len(names) == 1, "the good Save Point did not survive beside the bad one"
assert payload["checkpoints"][0]["name"] not in names
assert not any(
c["depth"] == 9999
for c in client.get(f"/api/adventures/{copy_id}/checkpoints").json()
)
# ------------------------------------------------------- the state and its audit
def test_the_accepted_state_events_come_back_with_their_before_values(moved):
source_events = _events(moved["source"]["id"])
copy_events = _events(moved["copy_id"])
assert copy_events == source_events
assert len(copy_events) == 12
def test_a_manual_correction_is_still_identifiable_as_one(moved):
"""C04's authority survives the move.
This is the state change no narration explains, and with the events omitted
it was indistinguishable from something the story established — the copy
showed the fact and could not say who asserted it.
"""
corrections = [
event for event in _events(moved["copy_id"])
if event["source"] == "manual_correction"
]
assert len(corrections) == 1
assert corrections[0]["event_type"] == "add_fact"
assert corrections[0]["payload"]["predicate"] == "keeper_of_the_lantern"
def test_the_proposals_come_back_and_the_events_still_name_them(moved):
"""The two tables arrive linked, not merely both present."""
with SessionLocal() as db:
proposals = (
db.query(models.StateProposal)
.filter(models.StateProposal.adventure_id == moved["copy_id"])
.all()
)
events = (
db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == moved["copy_id"])
.all()
)
ids = {p.id for p in proposals}
assert len(proposals) == 12
linked = [e for e in events if e.proposal_id is not None]
assert linked, "every event lost the proposal that produced it"
assert all(e.proposal_id in ids for e in linked), \
"an event points at a proposal that is not in this campaign"
# And the proposals point at this campaign's own turns, not the source's.
theirs = {row.id for row in _all_actions(moved["copy_id"])}
assert all(p.action_id in theirs for p in proposals if p.action_id is not None)
def test_a_state_event_naming_a_turn_the_file_does_not_hold_is_refused(client):
"""Unlike a Save Point, and for a stated reason.
An audit record that quietly did not arrive leaves a campaign whose state
cannot be explained, and it is the explanation a reader goes looking for
exactly when something looks wrong.
"""
m9_fixture.build(client, client.adv_id)
payload = export(client)
payload["stateEvents"][0]["action"] = 999_999
detail = refuses(client, payload)
assert "999999" in detail.replace(",", "")
def test_state_restoration_after_import_is_a_snapshot_read_not_a_replay(moved, client):
"""L02, and the bound M4 made load-bearing.
Undo, Redo and Save Point restore all resolve a coordinate and read the
state recorded there. If the import had dropped the per-position snapshots
and left only the events, every one of those would have to replay the
campaign — so the check is that each position in the copy holds the same
state as the same position in the original, walked the same way.
"""
copy_id = moved["copy_id"]
walked_copy, walked_source = [], []
for _ in range(3):
for adv_id, out in ((copy_id, walked_copy), (client.adv_id, walked_source)):
assert client.post(f"/api/adventures/{adv_id}/undo").status_code == 200
out.append(client.get(f"/api/adventures/{adv_id}/state").json()["document"])
assert walked_copy == walked_source
for _ in range(3):
for adv_id, out in ((copy_id, walked_copy), (client.adv_id, walked_source)):
assert client.post(f"/api/adventures/{adv_id}/redo").status_code == 200
out.append(client.get(f"/api/adventures/{adv_id}/state").json()["document"])
assert walked_copy == walked_source
def test_the_abandoned_line_still_holds_its_own_different_state(moved, client):
"""E01, after a move. Two futures, two states, and neither leaks."""
copy_id = moved["copy_id"]
at_head = client.get(f"/api/adventures/{copy_id}/state").json()["document"]
assert at_head != moved["source"]["tip_state"], (
"the fixture's two futures must differ, or this proves nothing"
)
# ---------------------------------------------- historical prompt provenance (§10)
def test_an_old_turn_can_still_show_what_it_was_actually_given(moved, client):
"""The M8 handoff, closed.
Inspect Context on a historical narrator turn in the *copy* returns the
prompt that turn was assembled from — the same sections, the same text — and
not a prompt rebuilt from the campaign as it stands now.
"""
source_turn, copy_turn = _first_narrator_turn(client, client.adv_id), \
_first_narrator_turn(client, moved["copy_id"])
original = _context_of(client, client.adv_id, source_turn)
restored = _context_of(client, moved["copy_id"], copy_turn)
assert restored["prompt"] == original["prompt"]
assert [s["label"] for s in restored["sections"]] == \
[s["label"] for s in original["sections"]]
assert restored["tokens"] == original["tokens"]
def test_the_old_turn_still_names_the_passages_it_was_shown(moved, client):
"""F06's provenance, restored. Including the text each passage supplied."""
turn = _first_narrator_turn(client, moved["copy_id"])
knowledge = _context_of(client, moved["copy_id"], turn)["knowledge"]
assert knowledge["used"], "the restored turn was shown no imported passage"
for record in knowledge["used"]:
assert record["text"], "a passage record arrived with no text"
assert record["classification"] in ("canon", "reference", "inspiration")
def test_the_evidence_outlives_the_source_being_deleted(moved, client):
"""`IMPORTED-KNOWLEDGE-DESIGN.md` §49-50, across a machine boundary.
The record holds the text rather than a pointer to a row that can go away,
so deleting the source in the copy leaves the old turn still able to say
what it was told — and the "open this source" link honestly goes quiet
rather than pointing somewhere else.
"""
copy_id = moved["copy_id"]
turn = _first_narrator_turn(client, copy_id)
shown = _context_of(client, copy_id, turn)["knowledge"]["used"]
was_shown = {record["source_id"] for record in shown}
assert was_shown, "nothing was shown, so there is nothing to outlive"
for source_id in was_shown:
assert client.delete(
f"/api/adventures/{copy_id}/knowledge/{source_id}"
).status_code == 204
after = _context_of(client, copy_id, turn)["knowledge"]["used"]
assert [r["text"] for r in after] == [r["text"] for r in shown]
assert [r["title"] for r in after] == [r["title"] for r in shown]
def test_a_restored_retrieval_record_points_at_this_machines_source(moved, client):
"""The one pointer the import translates, and why.
`source_id` names a row on the machine that wrote the file. Left alone it
would point the inspector's "open this source" control at whatever holds
that id here — nothing, or somebody else's file.
"""
copy_id = moved["copy_id"]
theirs = {
source["id"] for source in
client.get(f"/api/adventures/{copy_id}/knowledge").json()
}
turn = _first_narrator_turn(client, copy_id)
used = _context_of(client, copy_id, turn)["knowledge"]["used"]
named = [r["source_id"] for r in used if r["source_id"] is not None]
assert named, "no restored record names a source at all"
assert set(named) <= theirs, "a record points outside this campaign's library"
def test_a_snapshot_naming_a_source_the_file_does_not_carry_goes_quiet(moved, client):
"""A source deleted before the export was taken.
The record keeps its text and its filename and says `null` for the source,
which is the honest answer: this passage came from a file that is no longer
here, and here is what it said. It must not be pointed at a different file.
"""
payload = moved["bundle"]
assert any(m9_fixture.snapshot_in(a) for a in payload["actions"]), \
"the fixture carries no snapshot to edit"
edited = copy.deepcopy(payload)
rewritten = []
for action in edited["actions"]:
snapshot = m9_fixture.snapshot_in(action)
if not isinstance(snapshot, dict):
rewritten.append(action)
continue
for record in (snapshot.get("knowledge") or {}).get("used") or []:
record["source_id"] = 88_888
rewritten.append(m9_fixture.with_snapshot(action, snapshot))
edited["actions"] = rewritten
copy_id = bring_back(client, edited)
for row in _all_actions(copy_id):
snapshot = row.context_snapshot
if not isinstance(snapshot, dict):
continue
for record in (snapshot.get("knowledge") or {}).get("used") or []:
assert record["source_id"] is None
assert record["text"], "and the evidence itself is untouched"
def test_a_retried_turn_does_not_multiply_the_prompt(moved):
"""The prompt is stored once per turn, and travels once per turn.
A superseded take keeps only its own slices — its reply, its state proposal,
its token accounting — so a campaign someone retried twenty times does not
carry twenty copies of the largest thing in the file.
"""
superseded = [
snapshot for action in moved["bundle"]["actions"]
if not action.get("live", True)
for snapshot in [m9_fixture.snapshot_in(action)] if snapshot
]
assert superseded, "the fixture's retry left no superseded take with a record"
for take in superseded:
assert "sections" not in take
assert "prompt" not in take
# ------------------------------------------------------------------------ I05
def test_i05_the_library_survives_with_its_classifications_and_lifecycle(moved):
assert moved["copy"]["knowledge"] == moved["source"]["knowledge"]
assert len(moved["copy"]["knowledge"]) == 5
def test_the_disabled_source_is_still_disabled_and_the_hidden_one_still_hidden(
moved, client
):
library = client.get(f"/api/adventures/{moved['copy_id']}/knowledge").json()
by_file = {source["original_filename"]: source for source in library}
assert by_file["draft.md"]["enabled"] is False
assert by_file["secret.md"]["visibility"] == "hidden"
assert by_file["canon.md"]["always_include"] is True
assert by_file["canon.md"]["classification"] == "canon"
# And the filename really is only metadata: the title is the source's own,
# derived at import time from its content rather than from its path.
assert by_file["canon.md"]["title"] == "canon"
def test_the_copy_needs_no_original_file_and_is_searchable_at_once(moved, client):
"""§11. Nothing here reopens a path on the exporting machine.
The content came in the file, and the passages were rebuilt from it before
the import returned — so retrieval works with no reindex step and no
explanation owed to the reader.
"""
copy_id = moved["copy_id"]
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
assert all(source["index_state"] == "ready" for source in library)
assert all(source["chunk_count"] > 0 for source in library)
# The filename is metadata and nothing else: it never became a path.
assert all("/" not in source["original_filename"] for source in library)
def test_the_disabled_source_stays_out_of_retrieval_after_the_move(moved, client):
report = client.get(f"/api/adventures/{moved['copy_id']}/context").json()
used = {record["filename"] for record in report["knowledge"]["used"]}
assert "draft.md" not in used
# ----------------------------------------------------- summaries and memories
def test_the_summaries_come_back_on_the_coordinates_that_hold_them(moved):
assert moved["copy"]["summaries"] == moved["source"]["summaries"]
assert len(moved["copy"]["summaries"]) == 2
def test_an_abandoned_lines_summary_is_still_ineligible_after_the_move(moved):
"""E03 does not get a second chance through the import.
The fixture writes one summary on the line it later abandons and one at the
head it keeps. Both travel. Eligibility is not a stored flag — it is whether
the coordinate lies on the active capped lineage — so restoring the
coordinates restores the answer, including the "no".
"""
eligible = {
(text, trigger): is_eligible
for text, trigger, is_eligible in moved["copy"]["summaries"]
}
assert sorted(eligible.values()) == [False, True], (
"the copy should hold exactly one eligible and one ineligible summary"
)
assert eligible[("Aldric went back to the Lantern instead.", "manual")] is True
def test_the_abandoned_summary_does_not_reach_the_copys_next_prompt(moved, client):
"""E03, measured where it matters: the prompt the copy would send next.
The generated summary sits on the line the fixture abandoned, and the typed
one sits at the head. Only the second may reach a prompt. The provenance
record names the coordinate rather than carrying the text, so eligibility is
checked there and the leak is checked in the assembled prompt itself.
"""
copy_id = moved["copy_id"]
report = client.get(f"/api/adventures/{copy_id}/context").json()
prompt = report["prompt"]["system"] + report["prompt"]["story"]
with SessionLocal() as db:
adventure = db.get(models.Adventure, copy_id)
entitled = summaries.current(db, adventure)
every = {row.id: row for row in summaries.all_for(db, adventure)}
assert entitled is not None, "the copy is entitled to no summary at all"
assert entitled.trigger == "manual", "the abandoned line's summary is eligible"
abandoned = [row for row in every.values() if row.id != entitled.id]
assert abandoned, "the fixture's abandoned summary did not survive the move"
for row in abandoned:
assert row.text not in prompt, "an abandoned summary reached the prompt"
assert report["summary"]["id"] == entitled.id
def test_the_summary_mirror_agrees_with_the_lineage_after_import(moved, client):
"""M6-F1's defect must not arrive by a new route."""
copy_head = client.get(f"/api/adventures/{moved['copy_id']}").json()
with SessionLocal() as db:
adventure = db.get(models.Adventure, moved["copy_id"])
entitled = summaries.current(db, adventure)
assert copy_head["story_summary"] == (entitled.text if entitled else "")
def test_memory_authority_survives_rather_than_being_promoted(client):
"""F07. A heuristic memory must not become accepted story by being moved."""
m9_fixture.build(client, client.adv_id)
with SessionLocal() as db:
memory = (
db.query(models.Memory)
.filter(models.Memory.adventure_id == client.adv_id)
.order_by(models.Memory.id).first()
)
memory.authority = memorybank.HEURISTIC
db.commit()
copy_id = bring_back(client, export(client))
authorities = [row.authority for row in rows(copy_id, models.Memory)]
assert memorybank.HEURISTIC in authorities
assert authorities == [row.authority for row in rows(client.adv_id, models.Memory)]
def test_a_memory_comes_back_on_the_node_it_hangs_off(moved):
source_rows = [(m.text, m.depth) for m in rows(moved["source"]["id"], models.Memory)]
copy_rows = [(m.text, m.depth) for m in rows(moved["copy_id"], models.Memory)]
assert copy_rows == source_rows
# ---------------------------------------------------- L04, derived data rebuild
def test_l04_deleting_the_derived_indexes_and_rebuilding_changes_no_story(
moved, client
):
"""L04, on a copy rather than on the original.
Every physically derived structure is destroyed — passages, the FTS rows and
the vectors — and rebuilt from the source content the bundle carried. The
authoritative campaign must be identical either side, and retrieval must
work again afterwards.
"""
copy_id = moved["copy_id"]
before = m9_fixture.snapshot_of(client, copy_id)
with SessionLocal() as db:
db.query(models.KnowledgeEmbedding).filter(
models.KnowledgeEmbedding.adventure_id == copy_id
).delete(synchronize_session=False)
db.query(models.KnowledgeChunk).filter(
models.KnowledgeChunk.adventure_id == copy_id
).delete(synchronize_session=False)
db.commit()
empty = client.get(f"/api/adventures/{copy_id}/knowledge").json()
assert all(source["chunk_count"] == 0 for source in empty)
rebuilt = client.post(f"/api/adventures/{copy_id}/knowledge/reindex")
assert rebuilt.status_code == 200, rebuilt.text[:300]
after = client.get(f"/api/adventures/{copy_id}/knowledge").json()
assert all(source["chunk_count"] > 0 for source in after)
assert all(source["index_state"] == "ready" for source in after)
# Content, classification and lifecycle are untouched by a rebuild.
assert m9_fixture.snapshot_of(client, copy_id)["knowledge"] == before["knowledge"]
# And the authoritative campaign did not move.
assert m9_fixture.snapshot_of(client, copy_id)["transcript"] == before["transcript"]
assert m9_fixture.snapshot_of(client, copy_id)["state"] == before["state"]
# Retrieval works again.
report = client.get(f"/api/adventures/{copy_id}/context").json()
assert report["knowledge"]["used"]
def test_losing_the_semantic_half_leaves_the_lexical_half_working(moved, client):
"""M7's rule, which M9 must not regress during a rebuild.
Lexical retrieval is a supported production path, not a fallback, so a
campaign whose vectors are gone still finds its Canon.
"""
copy_id = moved["copy_id"]
with SessionLocal() as db:
db.query(models.KnowledgeEmbedding).filter(
models.KnowledgeEmbedding.adventure_id == copy_id
).delete(synchronize_session=False)
db.commit()
report = client.get(f"/api/adventures/{copy_id}/context").json()
assert report["knowledge"]["used"], "lexical retrieval stopped with the vectors"
def test_deleting_a_campaign_takes_its_lexical_index_with_it(moved, client):
"""M9 finding: the FTS index is a virtual table and nothing cascades into it.
Deleting a campaign dropped its passages and left one index row per passage
behind. Nothing read them — the search joins through `knowledge_chunks`, and
those were gone — so the leak was invisible until the id came round again.
"""
from sqlalchemy import text as sql
copy_id = moved["copy_id"]
with SessionLocal() as db:
before = db.execute(sql("SELECT COUNT(*) FROM knowledge_fts")).scalar()
assert before > 0
assert client.delete(f"/api/adventures/{copy_id}").status_code == 204
with SessionLocal() as db:
orphans = db.execute(sql("""
SELECT COUNT(*) FROM knowledge_fts
WHERE rowid NOT IN (SELECT id FROM knowledge_chunks)
""")).scalar()
assert orphans == 0
def test_an_orphaned_index_row_does_not_break_the_next_import(client):
"""The other half, and the one that repairs a database already damaged.
SQLite hands out the lowest free primary key, so an orphan left by an older
build is met head-on by the next campaign to import anything — in a campaign
with no connection to the one that leaked it. It used to be a 500 from an
ordinary upload, and Reindex could not clear it either, because
`clear_index` finds index rows through chunks that no longer exist.
"""
from sqlalchemy import text as sql
# An orphan, written the way a pre-M9 build would have left one.
with SessionLocal() as db:
db.execute(
sql("INSERT OR REPLACE INTO knowledge_fts (rowid, text) "
"VALUES (1, 'left behind by a deleted campaign')")
)
db.commit()
landed = m9_fixture.upload(
client, client.adv_id, "canon.md", m9_fixture.CANON_MD, "canon",
)
library = client.get(f"/api/adventures/{client.adv_id}/knowledge").json()
source = next(s for s in library if s["id"] == landed)
assert source["index_state"] == "ready"
assert source["chunk_count"] > 0
# And what the orphan said is gone rather than searchable.
with SessionLocal() as db:
stale = db.execute(sql(
"SELECT COUNT(*) FROM knowledge_fts WHERE text LIKE '%left behind%'"
)).scalar()
assert stale == 0
def test_reindex_repairs_an_index_that_lost_its_passages(client):
"""L04's repair path, exercised against the damage it exists to repair.
Deleting the chunk rows without the index rows is what a cascade used to do,
and Reindex is documented as the repair. It has to actually be one.
"""
m9_fixture.build(client, client.adv_id)
with SessionLocal() as db:
db.query(models.KnowledgeChunk).filter(
models.KnowledgeChunk.adventure_id == client.adv_id
).delete(synchronize_session=False)
db.commit()
rebuilt = client.post(f"/api/adventures/{client.adv_id}/knowledge/reindex")
assert rebuilt.status_code == 200, rebuilt.text[:400]
assert rebuilt.json()["failed"] == []
library = client.get(f"/api/adventures/{client.adv_id}/knowledge").json()
assert all(source["index_state"] == "ready" for source in library)
assert all(source["chunk_count"] > 0 for source in library)
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
assert report["knowledge"]["used"]
def test_a_rebuilt_index_does_not_reseed_an_abandoned_summary(moved, client):
"""M6's leak must not return through the rebuild path either."""
copy_id = moved["copy_id"]
client.post(f"/api/adventures/{copy_id}/knowledge/reindex")
eligible = {
(text, trigger): is_eligible
for text, trigger, is_eligible in
m9_fixture.snapshot_of(client, copy_id)["summaries"]
}
assert sorted(eligible.values()) == [False, True]
# ------------------------------------------------------------- story cards (§13)
def test_story_cards_still_travel_in_both_directions(client):
"""Compatibility-only, but compatibility means a round trip loses nothing."""
with SessionLocal() as db:
db.add(models.StoryCard(
adventure_id=client.adv_id, type="character", name="Gwen",
keys="gwen, innkeeper", entry="Gwen keeps the road-house.",
notes="From an older campaign.",
))
db.commit()
payload = export(client)
assert payload["storyCards"] == [{
"type": "character", "name": "Gwen", "keys": "gwen, innkeeper",
"entry": "Gwen keeps the road-house.", "notes": "From an older campaign.",
}]
copy_id = bring_back(client, payload)
assert [(c.name, c.entry) for c in rows(copy_id, models.StoryCard)] == \
[("Gwen", "Gwen keeps the road-house.")]
def test_a_story_card_no_longer_reaches_the_narrator(client):
"""§73: not an alternate untracked path around knowledge authority.
Before M9 a keyword match put `World Lore: <entry>` in front of the narrator
with no class, no visibility, no source, no way to switch it off and no row
in the context inspector. The rows stay and the round trip stays; the
injection does not.
"""
with SessionLocal() as db:
db.add(models.StoryCard(
adventure_id=client.adv_id, type="location", name="The road-house",
keys="road-house, roadhouse",
entry="The road-house at Fen Cross has burned down.",
))
db.commit()
m9_fixture._play(client, client.adv_id, "ask about the road-house",
"She shrugs and pours another.")
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
prompt = report["prompt"]["system"] + report["prompt"]["story"]
assert "World Lore" not in prompt
assert "has burned down" not in prompt
assert report["cards"] == []
def test_a_historical_snapshot_that_carried_cards_still_renders_them(client):
"""The `cards` key stays in the report shape, for the old evidence.
An old turn's record says story cards were included, and M9 has just made
that record portable. Removing the key would make a restored campaign's own
history unreadable.
"""
m9_fixture.build(client, client.adv_id)
payload = export(client)
for position, action in enumerate(payload["actions"]):
snapshot = m9_fixture.snapshot_in(action)
if isinstance(snapshot, dict) and "cards" in snapshot:
snapshot["cards"] = [
{"id": 7, "name": "Gwen", "keyword": "gwen", "included": True}
]
payload["actions"][position] = m9_fixture.with_snapshot(action, snapshot)
break
else:
pytest.fail("no snapshot in the fixture bundle to edit")
copy_id = bring_back(client, payload)
found = [
row.context_snapshot["cards"] for row in _all_actions(copy_id)
if isinstance(row.context_snapshot, dict)
and row.context_snapshot.get("cards")
]
assert found == [[{"id": 7, "name": "Gwen", "keyword": "gwen", "included": True}]]
# ----------------------------------------------- scene / media metadata (§14)
def test_the_scene_section_of_the_state_document_round_trips(moved):
"""There are no media tables in this build, and none were invented.
What exists is the `scene` section of the authoritative narrative state
document — `SPECIFICATION.md` §14's scene snapshot as this build represents
it — and it travels with the document, per position, like the rest of it.
"""
for action in moved["bundle"]["actions"]:
state = action.get("narrativeStateAfter")
if isinstance(state, dict):
assert "scene" in state
break
else:
pytest.fail("no per-position state document in the bundle")
assert "scene" in moved["bundle"]["narrativeState"]
# ------------------------------------------------------- I06 and local-only
def test_i06_the_export_carries_no_credential_of_any_kind(moved, client):
"""I06. Checked against the file's text, not against a list of columns.
The application needs no cloud key, which makes this easy — and is exactly
why it is worth testing rather than assuming. The inert `api_key` column is
still in the schema, and a future field could reach the bundle by being
added to a model the exporter walks.
"""
import json
with SessionLocal() as db:
settings = db.query(models.Settings).first()
settings.api_key = "enc:this-must-never-be-exported"
settings.endpoint_url = "http://127.0.0.1:11434/v1"
db.commit()
text = json.dumps(export(client))
assert "enc:this-must-never-be-exported" not in text
assert "api_key" not in text
assert "apiKey" not in text
# And no endpoint, model host or absolute filesystem path travels either.
assert "11434" not in text
assert "/home/" not in text
def test_the_bundle_names_no_path_that_could_become_one(moved):
"""H08, for the import direction. A filename is metadata, never a path."""
for source in moved["bundle"]["knowledge"]:
assert "/" not in source["originalFilename"]
assert "\\" not in source["originalFilename"]
assert ".." not in source["originalFilename"]
def test_a_bundle_carrying_a_traversal_filename_is_sanitised(client):
m9_fixture.build(client, client.adv_id)
payload = export(client)
payload["knowledge"][0]["originalFilename"] = "../../../etc/passwd"
copy_id = bring_back(client, payload)
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
assert all(".." not in source["original_filename"] for source in library)
assert all("/" not in source["original_filename"] for source in library)
# ------------------------------------------------------------------- helpers
def _texts(client, adv_id, every_branch=False) -> list[str]:
if not every_branch:
return [a["text"] for a in
client.get(f"/api/adventures/{adv_id}").json()["actions"]]
out = []
for branch in client.get(f"/api/adventures/{adv_id}/branches").json():
client.post(f"/api/adventures/{adv_id}/branches/{branch['id']}/switch")
out.extend(_texts(client, adv_id))
return out
def _head(adv_id) -> dict:
with SessionLocal() as db:
adventure = db.get(models.Adventure, adv_id)
return {"branch_id": adventure.head_branch_id, "depth": adventure.head_depth}
def _all_actions(adv_id) -> list[models.Action]:
with SessionLocal() as db:
from sqlalchemy.orm import undefer
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.options(undefer(models.Action.context_snapshot))
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
.all()
)
def _takes(adv_id) -> list[tuple[str, bool]]:
"""Every attempt at the retried turn, as text and liveness."""
with SessionLocal() as db:
adventure = db.get(models.Adventure, adv_id)
rows_at = (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id,
models.Action.type == "ai")
.order_by(models.Action.branch_id, models.Action.depth,
models.Action.id)
.all()
)
seen = {}
for row in rows_at:
seen.setdefault((row.branch_id, row.depth), []).append(row)
for group in seen.values():
if len(group) > 1:
return [(row.text, bool(row.live)) for row in group]
return []
def _take_pagers(client, adv_id) -> list[tuple[int, int]]:
"""The take pager under every narrator message, as (position, total)."""
page = client.get(f"/api/adventures/{adv_id}").json()
return [
(action.get("take_index"), action.get("take_count"))
for action in page["actions"] if action["type"] == "ai"
]
def _events(adv_id) -> list[dict]:
"""The accepted state events, with the ids left out."""
with SessionLocal() as db:
return [
{"event_type": row.event_type, "payload": row.payload,
"before": row.before, "source": row.source, "depth": row.depth,
"sequence": row.sequence}
for row in db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == adv_id)
.order_by(models.StateEvent.id).all()
]
def _first_narrator_turn(client, adv_id) -> int:
"""The id of the earliest narrator action that has a stored prompt."""
for row in _all_actions(adv_id):
if row.type == "ai" and row.live and isinstance(row.context_snapshot, dict) \
and row.context_snapshot.get("prompt"):
return row.id
raise AssertionError(f"campaign {adv_id} has no turn with a stored prompt")
def _context_of(client, adv_id, action_id) -> dict:
response = client.get(f"/api/adventures/{adv_id}/actions/{action_id}/context")
assert response.status_code == 200, response.text[:300]
return response.json()