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
@@ -246,6 +246,13 @@ class Adventure(Base):
|
||||
cascade="all, delete-orphan",
|
||||
order_by="KnowledgeSource.id",
|
||||
)
|
||||
# M10: how the campaign's entities look. Derived presentation metadata, not
|
||||
# story state — see `VisualProfile`.
|
||||
visual_profiles: Mapped[list["VisualProfile"]] = relationship(
|
||||
back_populates="adventure",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="VisualProfile.id",
|
||||
)
|
||||
|
||||
|
||||
class Branch(Base):
|
||||
@@ -802,6 +809,108 @@ class KnowledgeEmbedding(Base):
|
||||
chunk: Mapped[KnowledgeChunk] = relationship(back_populates="embedding")
|
||||
|
||||
|
||||
class VisualProfile(Base):
|
||||
"""M10: how one entity looks, so a future depiction can be consistent.
|
||||
|
||||
The only thing M10 persists, and the reason is that it was the only thing
|
||||
the media contract asks for that nothing already stored. The scene snapshot
|
||||
§5 asks for already exists as `narrative_state["scene"]` and has since M5;
|
||||
building a second one beside it would have been a duplicate representation
|
||||
with its own lineage rules to get wrong.
|
||||
|
||||
## Not story state, and structurally so
|
||||
|
||||
A visual profile is **presentation metadata**. Nothing here is a fact the
|
||||
story established: `MEDIA-EXTENSION-CONTRACT.md` §35 and §37 are explicit
|
||||
that a depiction — and therefore a description written to guide one — must
|
||||
never become canon on its own, and that promoting a visual detail into canon
|
||||
would have to be a deliberate act by the reader.
|
||||
|
||||
So these rows are deliberately **outside** the M5 pipeline. They are not
|
||||
events, they are not validated by `narrative/validate.py`, they are not in
|
||||
the state document, and they are not snapshotted per position. Writing one
|
||||
cannot change `narrative_state`, because nothing in `media/` imports the
|
||||
code that may. That is the guarantee, and it is a structural one rather than
|
||||
a rule somebody has to remember.
|
||||
|
||||
## Campaign-scoped, not per-position — which is the interesting decision
|
||||
|
||||
Every other derived record in this schema carries a `(branch_id, depth)`
|
||||
coordinate, because it describes a *moment*: a memory summarises a stretch,
|
||||
a summary covers a range, a snapshot records an outcome. A visual profile
|
||||
describes none of those. It says what someone looks like, and a character
|
||||
does not change appearance because the story forked.
|
||||
|
||||
Making it per-position would have been actively wrong twice over. It would
|
||||
have meant a profile written on one branch was invisible on another, so a
|
||||
reader who diverged would lose their cast's appearance — the opposite of the
|
||||
continuity the profile exists for. And it would have put a descriptor
|
||||
document into every per-position state snapshot, which M9 measured as
|
||||
already 74% of a campaign bundle; the profiles would have been duplicated
|
||||
once per turn to say something that never varies.
|
||||
|
||||
So the key is `(adventure_id, entity_key)` and there is exactly one profile
|
||||
per entity per campaign. It is stable across Undo, Redo, Save Point restore
|
||||
and divergence for the same reason it is simple: there is nothing there to
|
||||
move.
|
||||
|
||||
## `entity_key` is the M5 key, and no second identity namespace
|
||||
|
||||
The key is the entity key the narrative state already uses — `"mara"`,
|
||||
`"the_office"`, `"silver_key"` — not a new id, not a name, and not a media
|
||||
identifier. `MEDIA-EXTENSION-CONTRACT.md` §7-9 describe character, location
|
||||
and item profiles separately; this is one table for all three, because M5's
|
||||
entity model is genre-neutral by design (`DATA-MODEL.md` §9) and a
|
||||
character, a location, an item, a vehicle and a spaceship are all entities
|
||||
with a `type`. Splitting them here would have reintroduced the genre shape
|
||||
M5 spent a milestone removing.
|
||||
|
||||
There is no `kind` column for the same reason: the entity already has a
|
||||
`type`, and storing it again would be a second source of truth for one fact.
|
||||
|
||||
## The columns, and why they are shaped this way
|
||||
|
||||
The contract's examples are fantasy-shaped — hair, eyes, build; architecture,
|
||||
hearths, oil lamps — and the brief is explicit that they are examples rather
|
||||
than a schema. A fixed column per fantasy attribute would not hold an
|
||||
orbital station, a corporate office or a car.
|
||||
|
||||
So: `descriptors` is an open map of trait to value, `features` is a list of
|
||||
distinctive visible things, and `style_notes` is free text about how it
|
||||
should be rendered. `{"hair": "dark auburn"}` and
|
||||
`{"hull": "pitted white composite"}` are the same shape, and neither needed
|
||||
a migration to become possible.
|
||||
"""
|
||||
|
||||
__tablename__ = "visual_profiles"
|
||||
__table_args__ = (
|
||||
# One profile per entity per campaign. The uniqueness is the model: a
|
||||
# second profile for the same entity would be a second answer to "what
|
||||
# does this look like", with nothing to decide between them.
|
||||
UniqueConstraint("adventure_id", "entity_key", name="uq_visual_entity"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("adventures.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
#: The narrative-state entity key. Not a display name: two characters may
|
||||
#: share a name, and M9's report recorded that the state model permits it.
|
||||
entity_key: Mapped[str] = mapped_column(String(200))
|
||||
#: Trait -> value. Open by construction; see the class docstring.
|
||||
descriptors: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
#: Distinctive visible things, as short phrases.
|
||||
features: Mapped[list] = mapped_column(JSON, default=list)
|
||||
#: How it should be rendered, rather than what it is.
|
||||
style_notes: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
adventure: Mapped[Adventure] = relationship(back_populates="visual_profiles")
|
||||
|
||||
|
||||
class StoryCard(Base):
|
||||
"""Owned by either a scenario or an adventure (exactly one set)."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user