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
+119
View File
@@ -1420,3 +1420,122 @@ Optional Media Coordinator
```
No production media provider is required for v1.
## 90. As Implemented (M10)
The contract above is Phase 0B design. This section records what M10 built
against it, what it deliberately left unbuilt, and the three places where
implementation answered a question the contract left open. It is appended rather
than woven in, so the original contract stays readable as the document it is.
### 90.1 What was built
```text
app/media/packet.py §10-11 the scene packet, derived on read
app/media/profiles.py §7-9 visual profiles for any entity
app/media/providers.py §13, §21-28, §55 the contracts and the endpoint policy
app/models.py VisualProfile — the only table M10 adds
app/routers/adventures/visuals.py six endpoints, all read/write of the above
```
Four HTTP endpoints for profiles (list, read, write, delete) and one for the
packet. No coordinator, no queue, no worker, no provider adapter, no dependency
added.
### 90.2 §5 was already satisfied — the scene snapshot exists
The single most consequential finding of the milestone. §5 requires a persisted
or derived scene snapshot; **M5 had already built it**, and it has been carrying
lineage correctly for three milestones. `narrative_state["scene"]` holds the
summary, the location, who is present, and the `(branch_id, depth)` coordinate;
it is written by a validated `set_scene` event, snapshotted per position, and
restored on every head move.
That was verified rather than assumed — a probe played a campaign, diverged it,
and checked that the scene at each position was the scene that position had, that
Undo cleared it back to the state before, and that a bundle carried both
branches' scenes.
So M10 built **no scenes table**. §6 says the scene snapshot must never be
authoritative over the story; deriving it from the authoritative state on read is
the strongest available form of that guarantee, because there is no second copy
that could disagree.
### 90.3 §12 answered: the packet excludes more than the transcript
§12 says the packet must not require raw transcript access. The implementation
draws the line wider, and this is a deliberate reading rather than an omission.
The packet carries **what the story established at this position**: location,
present characters with their profiles, significant objects, an action summary,
continuity constraints, ambience, turn range, lineage. It excludes the raw
transcript, **all imported knowledge** (§7 of `IMPORTED-KNOWLEDGE-DESIGN.md`),
memories and summaries.
Excluding imported knowledge as a *class* is what makes the hidden-information
rule hold. A narrator-only Canon source — the mechanism a reader uses to keep a
secret from themselves — never reaches a depiction, and does not need a filter
that someone must remember to apply to each new secret. Once the story
*establishes* something through a validated event it is no longer narrator-only,
and it appears in the packet, because at that point it is something that
happened rather than something the narrator was told.
### 90.4 §14-15 left unbuilt, and why
`MediaJob` and `MediaAsset` are defined as contracts (`MediaRequest`,
`MediaResult`, `ProviderCapabilities`) and not as tables. A job queue with no
producer and no consumer would be speculative architecture whose shape would be
decided by a provider nobody has chosen yet; the codebase declined the same thing
once already, in M6's `derived_status` ("not a job queue"). §16-20, §45-47 —
provenance, cleanup, retries, lineage — are therefore also deferred, and they
should be designed against a real coordinator.
What M10 does guarantee for them is the part that would be expensive to retrofit:
the scene identity a future asset must reference (`c<adventure>:b<branch>:<start>-<end>`)
is derived from the campaign and position rather than allocated, so it is stable
across processes, restarts and re-derivation without a row to keep in step.
### 90.5 §7-9 collapsed into one table, deliberately
The contract describes character, location and item profiles in three sections.
The implementation has one `visual_profiles` table keyed by the M5 entity key,
because M5's entity model is genre-neutral by design and a character, a location,
an item, a vehicle and a spaceship are all entities with a `type`. Three tables —
or one table with a `kind` column duplicating the entity's own `type` — would
have reintroduced the genre shape M5 spent a milestone removing.
The fields are open by construction: `descriptors` is a trait map, `features` a
list, `style_notes` free text. `{"hair": "dark auburn"}` and
`{"hull": "pitted white composite"}` are the same shape. The contract's examples
are fantasy-shaped and the test fixture is deliberately not
(`backend/tests/m10_fixture.py`: four people in an office), because a schema
written while looking at hair and oil lamps acquires that shape without anyone
choosing it.
### 90.6 §27-28 implemented strictly
A media provider endpoint must be **loopback**. `providers.endpoint_rejection_reason`
reuses the local-only policy in `app/endpoints.py` — which resolves the address
rather than trusting the hostname — and then requires loopback in addition. This
is stricter than narrator inference, which permits a trusted LAN host: a GPU
rendering a reader's campaign is a machine that reader is sitting at. No TLS
verification bypass exists anywhere in the path.
There is no provider configuration setting, because none is needed yet, and a
setting that exists can be pointed at a cloud by mistake.
### 90.7 §24A implemented as an asymmetry in the type
`TranscriptionProvider.transcribe` returns a `DraftTranscription` carrying
`editable: bool = True` and **no commit method**. A transcriber can produce a
draft and structurally cannot submit one. §24A's rule — STT never bypasses the
authoritative commit path — is therefore enforced by the shape of the interface
rather than by a caller remembering it.
### 90.8 §35 and §37 hold structurally
Nothing in `app/media/` imports the code that writes narrative state, no media
event type exists in the state vocabulary, and every M10 test that touches the
media layer compares the authoritative document before and after and requires it
to be identical (`backend/tests/test_m10_authority.py`). A depiction cannot
become canon because there is no path by which it could.