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
@@ -1345,6 +1345,67 @@ Do not implement:
|
||||
|
||||
Future media providers can be added through defined local interfaces without redesigning core story authority/history.
|
||||
|
||||
## Status: COMPLETE — 2026-09-07, pending independent review
|
||||
|
||||
Implemented on `m10-media-hooks` from the signed M9 commit `44edece`.
|
||||
`planning/reports/M10-IMPLEMENTATION-REPORT.md` is the implementer's account,
|
||||
written for a reviewer.
|
||||
|
||||
**The finding that shaped the milestone: the scene snapshot already existed.**
|
||||
|
||||
The media contract's §5 asks for a persisted or derived scene snapshot, and M5
|
||||
built one three milestones ago. `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 with a probe — a campaign played, diverged, undone
|
||||
and exported — rather than taken from M9's report.
|
||||
|
||||
So M10 built **no scenes table**, and the Scene Packet is derived on read with a
|
||||
computed identity (`c<adventure>:b<branch>:<start>-<end>`) rather than an
|
||||
allocated one. A second scene store would have been a duplicate representation of
|
||||
the same fact, with its own lineage rules to get wrong; the lineage rules are the
|
||||
hard part, which is precisely the argument for reusing the ones that already
|
||||
work.
|
||||
|
||||
**What it delivered:**
|
||||
|
||||
- **One table, `visual_profiles`** — the only field in the contract's scene list
|
||||
that nothing already stored. Campaign-scoped rather than per-position, because
|
||||
a character does not change appearance when the story forks, and because
|
||||
per-position profiles would have cost 245 copies of the same 367 bytes in a
|
||||
120-turn campaign to say something that never varies.
|
||||
- **A scene packet built on read**, excluding the raw transcript, all imported
|
||||
knowledge, memories and summaries. Excluding imported knowledge *as a class* is
|
||||
what keeps a hidden Canon source out of a future depiction without a filter
|
||||
anyone has to remember to extend.
|
||||
- **Provider contracts as `typing.Protocol` structural types**, with an empty
|
||||
registry, no adapter, and no dependency added. Nothing imports a media library
|
||||
because none is installed.
|
||||
- **The STT asymmetry in the type**: a `DraftTranscription` is editable and has
|
||||
no commit method, so a transcriber structurally cannot bypass the authoritative
|
||||
commit path.
|
||||
- **A stricter endpoint policy than narration uses** — loopback only, reusing
|
||||
`endpoints.py`'s resolved-address check rather than trusting a hostname. No
|
||||
media configuration setting exists, because one that exists can be pointed at a
|
||||
cloud by mistake.
|
||||
- **Profiles travel in the bundle** with no format bump. M9's own semantic test
|
||||
decides it: an absent `visualProfiles` key is unambiguous, because a campaign
|
||||
with no profiles is the ordinary case. Older v3 files still import.
|
||||
|
||||
**A defect found by the milestone's own tests, and fixed here:**
|
||||
|
||||
- **A redundant index migration made two databases disagree.** M10 first shipped
|
||||
migration 93 creating `ix_visual_profiles_adventure`. `create_all` already
|
||||
builds `ix_visual_profiles_adventure_id` from the column's `index=True`, on
|
||||
fresh installs and existing databases alike — so an *upgraded* database ended
|
||||
up with both indexes and a fresh one with only the second. The comparison of a
|
||||
fresh schema against an upgraded schema is what caught it. **M10 adds no
|
||||
migration at all**; `LATEST_VERSION` stays 92.
|
||||
|
||||
**Carry-forward, unchanged by this milestone:** the four post-M8 playtest
|
||||
findings recorded above (browser title, post-Undo orientation, narration length,
|
||||
character identity) remain **M11's**. M10 neither implemented nor tested them.
|
||||
|
||||
---
|
||||
|
||||
# M11 — v1 Security, Long-Run, and Release Validation
|
||||
|
||||
@@ -829,6 +829,61 @@ media_asset:
|
||||
|
||||
Potential metadata includes prompt, seed, model, workflow, dimensions, duration, character references, and source turn range.
|
||||
|
||||
## 28A. Media Extension Points (M10, as implemented)
|
||||
|
||||
M10 implemented the seams the two sections above describe, and the implementation
|
||||
is mostly an account of what it did **not** build.
|
||||
|
||||
```text
|
||||
visual_profiles the one thing M10 persists
|
||||
id, adventure_id campaign-scoped; no branch coordinate, deliberately
|
||||
entity_key the M5 narrative-state key: "mara", "the_office"
|
||||
descriptors open map of trait -> value
|
||||
features list of distinctive visible things
|
||||
style_notes free text about how it should be rendered
|
||||
created_at, updated_at
|
||||
UNIQUE (adventure_id, entity_key)
|
||||
```
|
||||
|
||||
No `scenes` table. No `media_jobs` table. No `media_assets` table.
|
||||
|
||||
**The scene snapshot of §20 already exists**, and has since M5. It is
|
||||
`narrative_state["scene"]` — `summary`, `location`, `present[]`, and the
|
||||
`at: {branch_id, depth}` coordinate that says where it was written. It is
|
||||
produced by the validated `set_scene` event, snapshotted per position in
|
||||
`actions.narrative_state_after`, restored by the head move on every Undo, Redo,
|
||||
Retry and Save Point restore, and carried in the v3 bundle. Building a second
|
||||
scene record beside it would have been a duplicate representation of the same
|
||||
fact with its own lineage rules to get wrong — and the lineage rules are the hard
|
||||
part, which is exactly why the answer is to reuse the one that already works.
|
||||
|
||||
So the **Scene Packet** (`app/media/packet.py`) is *derived on read* and stored
|
||||
nowhere. Its identity is `c<adventure>:b<branch>:<start>-<end>`, computed from
|
||||
the campaign and the position rather than allocated, so the same position yields
|
||||
the same id in any process and after any restart without a row to keep in step.
|
||||
|
||||
`media_job` and `media_asset` (§27, §28) remain **unbuilt**. M10 defines their
|
||||
contracts as `typing.Protocol` structural types in `app/media/providers.py` —
|
||||
`MediaProvider`, `SpeechProvider`, `TranscriptionProvider`, and the
|
||||
`MediaRequest` / `MediaResult` / `DraftTranscription` shapes — with an empty
|
||||
registry. A queue with no producer and no consumer would be speculative
|
||||
architecture, and this codebase has already declined that once: M6's
|
||||
`derived_status` carries the note "not a job queue".
|
||||
|
||||
**Why a visual profile has no branch coordinate.** Every other derived record in
|
||||
the schema carries `(branch_id, depth)` because it describes a *moment*. A
|
||||
profile describes none: a character does not change appearance because the story
|
||||
forked. Making it per-position would have hidden a reader's cast from them the
|
||||
moment they diverged, and would have put a descriptor document into every
|
||||
per-position snapshot — measured at 245 copies of 367 bytes in a 120-turn
|
||||
campaign, to say something that never varies (`tools/m10_media_cost.py`).
|
||||
|
||||
**A profile is not a fact.** Nothing here is state the story established.
|
||||
Writing one cannot change `narrative_state`, and the guarantee is structural
|
||||
rather than remembered: nothing in `app/media/` imports the code that writes it.
|
||||
The reverse direction is the same rule seen from the other side — a depiction
|
||||
never becomes canon (`MEDIA-EXTENSION-CONTRACT.md` §35, §37).
|
||||
|
||||
## 29. Export Package
|
||||
|
||||
A campaign export should be capable of preserving:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+38
-19
@@ -3,8 +3,8 @@
|
||||
**This file is the index. Start here.**
|
||||
|
||||
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
|
||||
milestones **M1 through M8 implemented and accepted**, and **M9 implemented and
|
||||
awaiting review**. M1-M6 were accepted on the dates below (M3 and M4: 2026-09-03;
|
||||
milestones **M1 through M8 implemented and accepted**, and **M9 and M10
|
||||
implemented and awaiting review**. M1-M6 were accepted on the dates below (M3 and M4: 2026-09-03;
|
||||
M5: 2026-09-04; M6: 2026-09-06). M5 and M6 were each accepted only after an
|
||||
independent review found a real defect and a corrective pass fixed it.
|
||||
|
||||
@@ -27,8 +27,16 @@ a reviewer: a set of claims with the measurements attached, not yet a record of
|
||||
acceptance. M8's report has moved to `archive/milestone-reports/`, which is
|
||||
where a milestone report goes once the next milestone's report replaces it.
|
||||
|
||||
**Next: M10 — Future Media Extension Hooks Only.** It has not been started, and
|
||||
no brief for it exists.
|
||||
**M10 — Future Media Extension Hooks Only — is implemented and awaiting
|
||||
independent review** (2026-09-07). `reports/M10-IMPLEMENTATION-REPORT.md` is the
|
||||
implementer's account. It built the seam and no media: one `visual_profiles`
|
||||
table, a scene packet derived on read, provider contracts with an empty
|
||||
registry, and no dependency added. Its central finding is that the scene
|
||||
snapshot the media contract asks for **already existed**, built by M5.
|
||||
|
||||
**Next: M11 — v1 Security, Long-Run, and Release Validation.** It has not been
|
||||
started, and no brief for it exists. It also owns the four post-M8 hands-on
|
||||
playtest findings recorded in `BUILD-MILESTONES.md`.
|
||||
|
||||
**Package version:** see `VERSION.md`, which records what each revision changed
|
||||
and why.
|
||||
@@ -90,7 +98,7 @@ Two standing qualifications:
|
||||
| Document | What it is for |
|
||||
| --- | --- |
|
||||
| `SPECIFICATION.md` | What the product must do. The top of the authority order. |
|
||||
| `TECHNICAL-DESIGN.md` | The selected architecture, including what M1-M9 built, recorded as fact. |
|
||||
| `TECHNICAL-DESIGN.md` | The selected architecture, including what M1-M10 built, recorded as fact. |
|
||||
| `DATA-MODEL.md` | Entities, the stored head, branch disposition, and the v3 export contract. |
|
||||
| `STORY-BRANCH-SEMANTICS.md` | Undo/Redo/Retry/branch/take behavior, including the M3 ratifications. |
|
||||
| `CONTEXT-AND-MEMORY.md` | Prompt assembly, summarization, branch-safe memory. |
|
||||
@@ -118,9 +126,10 @@ Two standing qualifications:
|
||||
10. `BROWSER-UX-SPEC.md`
|
||||
11. `V1-ACCEPTANCE-TESTS.md`
|
||||
12. `DECISIONS/` — all of them; they are short.
|
||||
13. `reports/M9-IMPLEMENTATION-REPORT.md`, for what the most recent milestone
|
||||
actually left behind — reading it as a claim to check, not a record, until
|
||||
it is reviewed. Nothing in `planning/archive/` unless sent there.
|
||||
13. `reports/M10-IMPLEMENTATION-REPORT.md` and
|
||||
`reports/M9-IMPLEMENTATION-REPORT.md`, for what the most recent milestones
|
||||
actually left behind — read as claims to check, not records, until they are
|
||||
reviewed. Nothing in `planning/archive/` unless sent there.
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
@@ -151,14 +160,19 @@ work until Phase 0 closes — which Phase 0 satisfied on 2026-09-01. It is in
|
||||
`reports/` holds the report for the milestone most recently completed, because
|
||||
that is the one the next milestone's planning has to consult:
|
||||
|
||||
- `reports/M10-IMPLEMENTATION-REPORT.md` — the M10 implementation: the media
|
||||
seam, everything it deliberately did not build, and the evidence for K01-K04.
|
||||
Written by the implementer for an independent reviewer, so it is a set of
|
||||
claims with the measurements attached and **not** a record of acceptance.
|
||||
- `reports/M9-IMPLEMENTATION-REPORT.md` — the M9 implementation: the measured M8
|
||||
portability baseline it started from, the final bundle contract, and the
|
||||
evidence for every acceptance test it claims. Written by the implementer for
|
||||
an independent reviewer, so it is a set of claims with the measurements
|
||||
attached and **not** a record of acceptance. Its §W carries the M10-M11
|
||||
handoff.
|
||||
evidence for every acceptance test it claims. Its §W carries the M10-M11
|
||||
handoff and its §Y holds the post-M8 playtest findings.
|
||||
|
||||
**It stays here until M10's report replaces it.**
|
||||
**It stays here rather than moving to the archive**, against the usual
|
||||
rotation, because M9 has not been accepted yet: a reviewer of either milestone
|
||||
needs it, since M10 built on M9 and its baseline is M9's. It moves once M9 is
|
||||
accepted.
|
||||
|
||||
Completed earlier milestones are in `archive/milestone-reports/`, which M8's
|
||||
report joined when M9's was written: a milestone report is useful during the
|
||||
@@ -293,20 +307,25 @@ Milestone M9 COMPLETE — awaiting review (2026-09-07)
|
||||
migration hardening bundle format v3; SQLite online backup
|
||||
|
|
||||
v
|
||||
Milestone M10 NEXT — not started
|
||||
future media extension hooks see BUILD-MILESTONES.md
|
||||
Milestone M10 COMPLETE — awaiting review (2026-09-07)
|
||||
future media extension hooks reports/M10-IMPLEMENTATION-REPORT.md
|
||||
scene packet derived, not stored; no media
|
||||
|
|
||||
v
|
||||
Milestone M11 see BUILD-MILESTONES.md
|
||||
Milestone M11 NEXT — not started
|
||||
v1 security, long-run, release see BUILD-MILESTONES.md
|
||||
validation also owns the post-M8 playtest findings
|
||||
```
|
||||
|
||||
## Stop Rule
|
||||
|
||||
**One milestone at a time. Do not begin a milestone before its brief exists.**
|
||||
|
||||
**No M10 brief has been prepared**, and M9 is not accepted — it is implemented
|
||||
and awaiting an independent review. Writing the M10 brief is the action after
|
||||
that review closes, informed by the M9 report's §W.
|
||||
**No M11 brief has been prepared**, and neither M9 nor M10 is accepted — both
|
||||
are implemented and awaiting independent review. Writing the M11 brief is the
|
||||
action after those reviews close, informed by the M9 report's §W, the M10
|
||||
report's handoff, and the four post-M8 playtest findings in
|
||||
`BUILD-MILESTONES.md`.
|
||||
|
||||
All three questions the M8 debt raised against M9 are settled and recorded:
|
||||
the bundle carries historical context snapshots (`DATA-MODEL.md` §29); story
|
||||
|
||||
@@ -766,6 +766,43 @@ The browser should access them through:
|
||||
|
||||
Do not expose arbitrary filesystem browsing.
|
||||
|
||||
## 42A. Media Endpoints As Implemented (M10) — narrower than this document allows
|
||||
|
||||
M10 built the media seam and **did not widen the trust boundary**. Two notes,
|
||||
because in one place the implementation is deliberately stricter than the text
|
||||
above, and a stricter implementation than the threat model describes is still a
|
||||
discrepancy worth writing down.
|
||||
|
||||
**Media endpoints are loopback only.** §73's pass condition permits "explicitly
|
||||
configured trusted-LAN Ollama/media endpoints". `providers.endpoint_rejection_reason`
|
||||
allows that for narration and refuses it for media: it applies the shared
|
||||
local-only policy in `app/endpoints.py` — which resolves the address rather than
|
||||
trusting the hostname, so `localhost.evil.example` does not pass — and then
|
||||
requires loopback in addition. The reasoning is that a GPU rendering someone's
|
||||
campaign is a machine that person is sitting at, and that a picture of a scene
|
||||
carries the scene with it. If a later milestone finds a real trusted-LAN media
|
||||
use, that is a decision to make explicitly, not a limit to relax quietly.
|
||||
|
||||
**Nothing is contacted, and nothing can be configured to be.** M10 adds no
|
||||
provider adapter, no HTTP client, and no media endpoint *setting* — a setting
|
||||
that exists is a setting that can be pointed at a cloud by mistake. The registry
|
||||
ships empty; no module under `app/media/` imports `httpx`, `requests`,
|
||||
`urllib.request`, `socket`, `aiohttp` or `subprocess`, and that is asserted by
|
||||
test rather than by inspection (`backend/tests/test_m10_no_media.py`). No TLS
|
||||
verification bypass exists anywhere in the path.
|
||||
|
||||
**§41's media-metadata concern is unreached**, because no media is generated and
|
||||
no asset is stored. It stays open for whichever milestone builds a coordinator.
|
||||
|
||||
**One new boundary that is not a network one.** The Scene Packet is the input a
|
||||
future provider would receive, so what it carries is a disclosure decision. It
|
||||
carries what the *story* established at a position and excludes the raw
|
||||
transcript, all imported knowledge, memories and summaries — so a hidden Canon
|
||||
source (§56's story secrets) cannot reach a depiction. The exclusion is by class
|
||||
rather than by filtering marked secrets, which is what makes it hold for a secret
|
||||
nobody thought to mark. Tested with a sentinel in a hidden source, alongside a
|
||||
positive control proving the narrator did receive it.
|
||||
|
||||
## 43. Logging
|
||||
|
||||
Logs should minimize story-content exposure.
|
||||
|
||||
@@ -1098,6 +1098,64 @@ microphone/audio -> local STT -> editable draft -> normal user submission
|
||||
|
||||
STT never bypasses the ordinary authoritative story commit path.
|
||||
|
||||
### 15.1 As implemented (M10)
|
||||
|
||||
The boundary above is built. What follows is what it turned out to be.
|
||||
|
||||
**The scene snapshot was already there.** §15 says "persist or derive", and the
|
||||
answer is *derive*, because M5 had persisted it three milestones earlier:
|
||||
`narrative_state["scene"]` holds `summary`, `location`, `present[]` and the
|
||||
`at: {branch_id, depth}` coordinate, written by the validated `set_scene` event
|
||||
and snapshotted per position. It already restores correctly through Undo, Redo,
|
||||
Retry, Save Point restore and divergence, because the head move restores the
|
||||
whole state document and the scene is part of it. A second scene store would
|
||||
have had to reimplement all of that, and would have been a second answer to
|
||||
"where is the story now".
|
||||
|
||||
So the seam is three modules under `app/media/`, and only one of them has a
|
||||
table:
|
||||
|
||||
```text
|
||||
app/media/packet.py the normalized scene packet §15 names, built on read
|
||||
app/media/profiles.py visual character/location profiles — the one field in
|
||||
§15's list that nothing already stored
|
||||
app/media/providers.py the contracts a future coordinator implements
|
||||
```
|
||||
|
||||
`GET /api/adventures/{id}/scene-packet` returns the packet; the four
|
||||
`visual-profiles` endpoints read and write profiles. Nothing else in the
|
||||
application calls either — no turn, no prompt, no context section.
|
||||
|
||||
**What the packet contains, and the rule behind it.** Location, characters
|
||||
present with their profiles, significant objects, an action summary, continuity
|
||||
constraints, ambience, the source turn range and the lineage coordinate. What it
|
||||
deliberately excludes is the more interesting half: the raw transcript, all
|
||||
imported knowledge, memories and summaries. The rule is *what the story
|
||||
established at this position*, not *everything the narrator was told* — which is
|
||||
what keeps a hidden Canon source out of a depiction without needing a filter
|
||||
that someone has to remember to apply to each new secret.
|
||||
|
||||
**Story engine functional with media disabled** is the milestone's central
|
||||
acceptance condition rather than a footnote, and it is tested as one
|
||||
(`backend/tests/test_m10_no_media.py`): a whole campaign — turns, state
|
||||
extraction, memory and summary activity, knowledge retrieval, Undo, Redo, Retry,
|
||||
Save Point restore, restart — with an empty provider registry, no media setting
|
||||
in existence, and no media row written. Media readiness is inert until something
|
||||
uses it, and nothing does yet.
|
||||
|
||||
**STT asymmetry.** The `TranscriptionProvider` contract returns a
|
||||
`DraftTranscription` with `editable: bool = True` and no commit method, so the
|
||||
diagram above is enforced by the shape of the interface: a transcriber can
|
||||
produce a draft and cannot submit one. The ordinary authoritative commit path is
|
||||
the only way in.
|
||||
|
||||
**Endpoint policy.** A future media provider endpoint is checked by
|
||||
`providers.endpoint_rejection_reason`, which reuses the local-only policy in
|
||||
`app/endpoints.py` and then requires loopback in addition — stricter than
|
||||
narrator inference, which permits a trusted LAN host. A GPU that renders a
|
||||
reader's campaign is a machine that reader is sitting at. No provider
|
||||
configuration setting exists to point anywhere, because none is needed yet.
|
||||
|
||||
## 16. Database Direction
|
||||
|
||||
SQLite remains the selected v1 authoritative store.
|
||||
|
||||
@@ -2084,6 +2084,24 @@ Reach a scene involving multiple characters and a clear location.
|
||||
### Pass
|
||||
Application can persist a structured scene representation sufficient for future media use.
|
||||
|
||||
### Result — PASS, and it was already passing (M10, 2026-09-07)
|
||||
|
||||
The scene representation is `narrative_state["scene"]` and has existed since M5:
|
||||
`summary`, `location`, `present[]`, and the `at: {branch_id, depth}` coordinate
|
||||
that says where it was written. It is produced by the validated `set_scene`
|
||||
event, snapshotted per position in `actions.narrative_state_after`, restored on
|
||||
every head move, and carried in the v3 bundle.
|
||||
|
||||
M10 verified this with a probe rather than trusting the earlier report — a
|
||||
campaign played to a scene, diverged, undone and exported — and then built the
|
||||
normalized packet on top of it (`app/media/packet.py`,
|
||||
`GET /api/adventures/{id}/scene-packet`) instead of a second scene store.
|
||||
|
||||
Tests: `backend/tests/test_m10_media_hooks.py` (the packet's contents and
|
||||
bounds), `test_m10_lineage.py` (the scene follows the active lineage through
|
||||
Undo, Redo, Retry, divergence, Save Point restore and two genuine process
|
||||
restarts).
|
||||
|
||||
---
|
||||
|
||||
## K02 — Visual Character Profile
|
||||
@@ -2093,6 +2111,22 @@ Application can persist a structured scene representation sufficient for future
|
||||
### Pass
|
||||
Character can retain optional stable visual descriptors.
|
||||
|
||||
### Result — PASS (M10, 2026-09-07)
|
||||
|
||||
`visual_profiles`, keyed by `(adventure_id, entity_key)` — the M5 entity key, not
|
||||
a new identity namespace — with an open `descriptors` map, a `features` list and
|
||||
free `style_notes`. Read and written through
|
||||
`/api/adventures/{id}/visual-profiles[/{entity_key}]`.
|
||||
|
||||
**Optional** is tested as well as stated: the fixture leaves one character
|
||||
deliberately unprofiled, and the packet reports `visual_profile: null` for them
|
||||
rather than an empty profile, because "nobody has decided what Roger looks like"
|
||||
and "Roger looks like nothing" are different answers to a future provider.
|
||||
|
||||
**Stable** means campaign-scoped rather than per-position: a character does not
|
||||
change appearance because the story forked, so a profile survives Undo, Redo,
|
||||
divergence and Save Point restore unchanged, and travels in the bundle.
|
||||
|
||||
---
|
||||
|
||||
## K03 — Visual Location Profile
|
||||
@@ -2102,6 +2136,19 @@ Character can retain optional stable visual descriptors.
|
||||
### Pass
|
||||
Location can retain optional visual continuity descriptors.
|
||||
|
||||
### Result — PASS, through the same mechanism as K02 (M10, 2026-09-07)
|
||||
|
||||
There is no separate location table. M5's entity model is genre-neutral and a
|
||||
location is an entity with a `type`, so one `visual_profiles` table serves
|
||||
characters, locations and items alike. Splitting them would have reintroduced the
|
||||
genre shape M5 spent a milestone removing, and a `kind` column would have been a
|
||||
second copy of the entity's own type.
|
||||
|
||||
The fixture is deliberately non-fantasy — an open-plan office under flat
|
||||
fluorescent light — because the contract's examples are fantasy-shaped and a
|
||||
schema written while looking at them acquires that shape without anyone choosing
|
||||
it.
|
||||
|
||||
---
|
||||
|
||||
## K04 — Attach Media Asset to Scene
|
||||
@@ -2116,6 +2163,27 @@ A local dummy/test image can be associated with a scene/turn without altering st
|
||||
If media tables are deferred:
|
||||
- architecture/types should demonstrate equivalent extension point.
|
||||
|
||||
### Result — PASS on the deferred branch (M10, 2026-09-07)
|
||||
|
||||
Media tables are **deferred deliberately**, so this is reported against the
|
||||
acceptance text's second clause rather than its first.
|
||||
|
||||
`app/media/providers.py` defines the extension point as structural contracts:
|
||||
`MediaRequest`, `MediaResult`, `ProviderCapabilities`, `DraftTranscription`, and
|
||||
the `MediaProvider` / `SpeechProvider` / `TranscriptionProvider` protocols, with
|
||||
a registry that is empty and stays empty. A test registers a dummy provider,
|
||||
builds a scene packet, generates a fake PNG carrying the packet's `scene_id` as
|
||||
provenance, and confirms the story model is byte-for-byte unchanged — which is
|
||||
the equivalence the clause asks for.
|
||||
|
||||
`media_jobs` and `media_assets` were not built because a queue with no producer
|
||||
and no consumer would be speculative architecture, shaped by a provider nobody
|
||||
has chosen; M6 declined the same thing (`derived_status`: "not a job queue").
|
||||
The part that would be expensive to retrofit is guaranteed now: the scene
|
||||
identity a future asset must reference is *derived* from campaign and position
|
||||
(`c<adventure>:b<branch>:<start>-<end>`), so it is stable across processes and
|
||||
restarts without a row to keep in step.
|
||||
|
||||
---
|
||||
|
||||
## K05 — Generate Local Image
|
||||
|
||||
+39
-2
@@ -1,8 +1,45 @@
|
||||
# Planning Package Version
|
||||
|
||||
- **Package:** Adventure Storyteller Planning Package v3.5
|
||||
- **Package:** Adventure Storyteller Planning Package v3.6
|
||||
- **Revision date:** 2026-09-07
|
||||
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M8 implemented and accepted**; **M9 implemented and awaiting independent review** (2026-09-07). M10 has not been started.
|
||||
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M8 implemented and accepted**; **M9 and M10 implemented and awaiting independent review** (2026-09-07). M11 has not been started.
|
||||
|
||||
## v3.6 — M10 implemented: future media extension hooks only (2026-09-07)
|
||||
|
||||
M10 built the media seam and no media. The documentation change is mostly a
|
||||
record of what was deliberately **not** built, because that is the part a later
|
||||
reader will otherwise re-litigate.
|
||||
|
||||
| Document | Change | Kind |
|
||||
| --- | --- | --- |
|
||||
| `DATA-MODEL.md` | **New §28A**, the media extension points as implemented: one `visual_profiles` table, no scenes table, no job or asset tables, and why each. | as-implemented record |
|
||||
| `TECHNICAL-DESIGN.md` | **New §15.1** under the Scene and Future Media Boundary: what the seam turned out to be, what the packet carries and excludes, the STT asymmetry, the endpoint policy. | as-implemented record |
|
||||
| `MEDIA-EXTENSION-CONTRACT.md` | **New §90**, appended rather than woven in so the Phase 0B contract stays readable. Records the three places implementation answered an open question, and the sections left unbuilt. | as-implemented record |
|
||||
| `BUILD-MILESTONES.md` | **M10 status block**: the finding that shaped the milestone, what shipped, and the migration defect its own tests caught. | milestone status |
|
||||
| `V1-ACCEPTANCE-TESTS.md` | **K01-K04 results.** K01 passes and *was already passing*; K04 is reported on the acceptance text's deferred branch, as its own wording provides for. | acceptance evidence |
|
||||
| `SECURITY-THREAT-MODEL.md` | **New §42A.** The trust boundary did not widen; in one place the implementation is deliberately **narrower** than §73 permits, which is still a discrepancy worth recording. | boundary note |
|
||||
| `README.md`, `DEVELOPMENT.md` | The `media/` package in the architecture map, the backend test count (1,189), and the stricter rule a future media endpoint will meet. | developer docs |
|
||||
| `reports/M10-IMPLEMENTATION-REPORT.md` | New. The implementer's account, written for a reviewer. | milestone report |
|
||||
|
||||
**The decision behind the whole milestone**: the scene snapshot the media
|
||||
contract asks for **already existed** as `narrative_state["scene"]`, built by M5
|
||||
and carrying lineage correctly since. Verified with a probe rather than taken
|
||||
from an earlier report. So M10 added no scenes table, and derives the scene
|
||||
packet on read.
|
||||
|
||||
**Two decisions recorded rather than assumed:**
|
||||
|
||||
- **The bundle format stays `ai-dnd-adventure-v3`.** M9's own semantic test —
|
||||
does omission create ambiguity about what an older file *could* have recorded?
|
||||
— says no: a campaign with no visual profiles is the ordinary case, so an
|
||||
absent key unambiguously means "none". Older v3 files still import.
|
||||
- **M10 adds no migration.** A `CREATE INDEX` migration was written first and
|
||||
removed: `create_all` already builds the table and the index declared on its
|
||||
column, so the migration left an upgraded database holding an index a fresh
|
||||
install did not have. `LATEST_VERSION` stays 92.
|
||||
|
||||
**The four post-M8 playtest findings below remain M11's** and are untouched by
|
||||
this entry.
|
||||
|
||||
## v3.5 — Post-M8 hands-on playtest findings recorded (2026-09-07)
|
||||
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
# M10 — Future Media Extension Hooks Only
|
||||
|
||||
**Implementation report, written for an independent reviewer.**
|
||||
|
||||
Branch `m10-media-hooks`, from the signed M9 commit `44edece`. Implemented
|
||||
2026-09-07. This is a set of claims with the evidence attached; it is not a
|
||||
record of acceptance.
|
||||
|
||||
**The one-sentence version:** the scene snapshot the media contract asks for
|
||||
already existed, built by M5, so M10 built the seam around it and no media —
|
||||
one table, a packet derived on read, provider contracts with an empty registry,
|
||||
no dependency, no network, and no change to a single reader-facing surface.
|
||||
|
||||
---
|
||||
|
||||
## A. Repository baseline
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **M9 base commit** | `44edece67e7f65bacf78010ef56f98c3c8864073` — *"M9: a campaign you can actually get back"* |
|
||||
| **Signature** | `git verify-commit 44edece` → **Good signature**, RSA key `02C9BF7D8A4A77DF7A8905617D8AE19DB5C68569`, "JesseMarkowitz", trust `[ultimate]`. `%G?` = `G`. |
|
||||
| **Branch** | `m10-media-hooks`, created from that commit. The working tree was clean at the start. |
|
||||
| **Upstream ancestry** | `upstream` = `https://github.com/parththakkar106/AI-DnD.git`. `git merge-base --is-ancestor d72f7c1b HEAD` → true: the fork point is still an ancestor, so this remains a fork rather than a rewrite. |
|
||||
| **License / provenance** | `LICENSE` unchanged — md5 `07fde30437134836e2ee875e82a7cd31`, still MIT, still "Copyright (c) 2026 Parth Thakkar". `PROVENANCE.md` unchanged: M10 adds only new files under `backend/app/media/`, one router, one model and tests. **No dependency was added** to `requirements.txt` or `package.json`. |
|
||||
|
||||
---
|
||||
|
||||
## B. Architecture implemented
|
||||
|
||||
### B.1 The finding that decided the shape of the milestone
|
||||
|
||||
`MEDIA-EXTENSION-CONTRACT.md` §5 asks for a persisted or derived scene snapshot
|
||||
carrying location, participants, objects, actions, ambience, profiles,
|
||||
continuity constraints, and a turn range with lineage.
|
||||
|
||||
**Most of it already existed, and had since M5.** `narrative_state["scene"]`
|
||||
holds:
|
||||
|
||||
```json
|
||||
{"summary": "...", "location": "office", "present": ["bill", "alice", "roger"],
|
||||
"at": {"branch_id": 3, "depth": 4}}
|
||||
```
|
||||
|
||||
written by the validated `set_scene` typed event, snapshotted per position in
|
||||
`actions.narrative_state_after`, restored by `attempts.restore_state` on every
|
||||
head move, and carried in the M9 v3 bundle.
|
||||
|
||||
That was **verified rather than inherited from M9's report**: a probe played a
|
||||
campaign to a scene ("Mara enters the cellar" at branch 1, depth 4), undid it and
|
||||
confirmed the scene cleared to `{}`, diverged to a second continuation ("Mara
|
||||
remains upstairs" at branch 3, depth 4), confirmed both were retained and
|
||||
distinguishable, and confirmed a bundle carried both.
|
||||
|
||||
So **M10 created no scenes table.** A second scene store would have been a
|
||||
duplicate representation of the same fact, with its own lineage rules to get
|
||||
wrong — and the lineage rules are the hard part, which is the argument for
|
||||
reusing the ones that already work rather than against it.
|
||||
|
||||
### B.2 What exists now
|
||||
|
||||
```text
|
||||
backend/app/media/__init__.py 66 lines the finding, recorded where a
|
||||
future implementer will hit it
|
||||
backend/app/media/packet.py 328 lines the Scene Packet, built on read
|
||||
backend/app/media/profiles.py 220 lines visual profiles: the one thing
|
||||
§5 asks for that nothing stored
|
||||
backend/app/media/providers.py 332 lines contracts + endpoint policy
|
||||
backend/app/routers/adventures/visuals.py 131 four profile endpoints + the packet
|
||||
backend/app/models.py +1 model VisualProfile
|
||||
```
|
||||
|
||||
**Scene representation** — derived, not stored. `packet.build(db, adventure,
|
||||
start=, end=)` reads `narrative_store.current(adventure)`, which is the
|
||||
authoritative document at the active head, and returns:
|
||||
|
||||
```text
|
||||
scene_id derived identity, below
|
||||
campaign {id, title}
|
||||
turn_range {branch_id, start, end}
|
||||
lineage the head-capped branch lineage, as coordinates
|
||||
location entity view + its visual profile, or null
|
||||
characters present entities, each with its profile or null
|
||||
objects significant items, held or in the location
|
||||
action_summary the scene's own summary text
|
||||
continuity_constraints what must stay true in a depiction
|
||||
ambience {time_of_day, lighting, mood} — present, empty, §B.5
|
||||
source {packet_version: 1, head_depth}
|
||||
```
|
||||
|
||||
**Scene identity** — `c<adventure>:b<branch>:<start>-<end>`, e.g.
|
||||
`c7:b3:4-4`. **Derived, not allocated.** The same position yields the same id in
|
||||
any process, after any restart, and after the packet is thrown away and rebuilt,
|
||||
with no row to keep in step. `parse_scene_id()` reads it back. This is the part
|
||||
of a future `media_assets` table that would be expensive to retrofit, so it is
|
||||
guaranteed now even though the table is not built.
|
||||
|
||||
**Turn-range representation** — depths on the branch the scene was set on.
|
||||
Defaults to the scene's own single position; a caller passing `start` and `end`
|
||||
describes a stretch, which is what a future video provider would ask for
|
||||
(§31 of the contract, multi-turn packets).
|
||||
|
||||
**Lineage association** — the packet carries `turn_range.branch_id` and the
|
||||
head-capped `lineage` array. The scene's coordinate comes from the scene's own
|
||||
`at`, not from the head, and the difference is deliberate: a story can move on
|
||||
without re-establishing the scene, and a picture belongs to the moment the scene
|
||||
was set rather than to a later turn that did not change it.
|
||||
|
||||
**Visual profiles** — `visual_profiles`, keyed `(adventure_id, entity_key)`,
|
||||
with an open `descriptors` map, a `features` list and free `style_notes`. Four
|
||||
endpoints under `/api/adventures/{id}/visual-profiles` — list, read, write,
|
||||
delete. Campaign-scoped, not
|
||||
per-position (§D, §C.3).
|
||||
|
||||
**Provider-neutral interfaces** — `typing.Protocol` structural types, so a
|
||||
future adapter satisfies them by shape and imports nothing from here:
|
||||
|
||||
```text
|
||||
MediaProvider capabilities() -> ProviderCapabilities
|
||||
generate(MediaRequest) -> MediaResult
|
||||
SpeechProvider speak(...) -> MediaResult
|
||||
TranscriptionProvider transcribe(...) -> DraftTranscription
|
||||
```
|
||||
|
||||
with `MediaRequest`, `MediaResult`, `ProviderCapabilities`, `DraftTranscription`
|
||||
and `MediaProviderError` beside them, and a registry (`register`, `unregister`,
|
||||
`registered`, `for_kind`) that **is empty and ships empty**.
|
||||
|
||||
**STT draft-input contract** — `DraftTranscription` carries
|
||||
`editable: bool = True` and **has no commit method**. A transcriber can produce a
|
||||
draft and structurally cannot submit one; the ordinary authoritative commit path
|
||||
is the only way in. §24A's rule is enforced by the shape of the type rather than
|
||||
by a caller remembering it.
|
||||
|
||||
**Request/job/asset persistence** — **does not exist.** `MediaRequest` and
|
||||
`MediaResult` are contracts; there are no `media_jobs` or `media_assets` tables.
|
||||
See §F/K04 for the reasoning and how it is reported.
|
||||
|
||||
### B.3 Nothing calls any of it
|
||||
|
||||
No turn, prompt, context section, health check or startup path touches
|
||||
`app/media/`. Asserted structurally: `test_m10_no_media.py` parses the import
|
||||
statements of `turns.py`, `context/builder.py`, `narrative/apply.py`,
|
||||
`narrative/store.py`, `tree.py`, `head.py` and `memorybank.py` and requires that
|
||||
none imports the media package.
|
||||
|
||||
### B.4 What the packet excludes, which is the more interesting half
|
||||
|
||||
Excluded: the raw transcript, **all imported knowledge**, memories, summaries,
|
||||
and the state document's facts, relationships and threads.
|
||||
|
||||
The rule is *what the story established at this position*, not *everything the
|
||||
narrator was told*. Excluding imported knowledge **as a class** rather than
|
||||
filtering marked secrets is what makes §H hold for a secret nobody thought to
|
||||
mark: there is no filter to forget to extend.
|
||||
|
||||
### B.5 One deliberate gap
|
||||
|
||||
`ambience` returns `{time_of_day: null, lighting: null, mood: null}`. The fields
|
||||
are in the shape because a provider adapter should not have to branch on their
|
||||
absence; they are empty because filling them would mean extending the `set_scene`
|
||||
event, which is on the prompt path — a change to what the narrator is asked for,
|
||||
which is not M10's to make. Stated here rather than left to be discovered.
|
||||
|
||||
---
|
||||
|
||||
## C. Authority analysis
|
||||
|
||||
```text
|
||||
AUTHORITATIVE DERIVED
|
||||
┌────────────────────────────────┐ ┌──────────────────────────┐
|
||||
│ actions (the transcript) │ │ Scene Packet │
|
||||
│ state_events / state_proposals │──►│ built on read │
|
||||
│ narrative_state │ │ stored nowhere │
|
||||
│ narrative_state_after (per pos)│ │ identity computed │
|
||||
│ branches, head_branch/depth │ │ │
|
||||
│ checkpoints │ │ (future: media assets) │
|
||||
└────────────────────────────────┘ └──────────────────────────┘
|
||||
▲ │
|
||||
└────────── NO PATH ◄──────────┘
|
||||
|
||||
visual_profiles ── presentation metadata, campaign-scoped.
|
||||
Written by the reader, read by the packet, never by the story.
|
||||
```
|
||||
|
||||
### C.1 The reverse path does not exist, proved three ways
|
||||
|
||||
**By structure.** `test_m10_authority.py::test_the_media_package_imports_nothing_that_writes_state`
|
||||
requires that no file under `app/media/` references `narrative.apply`,
|
||||
`set_current`, `head.move_to` or `tree.place_action`. `narrative.model` and
|
||||
`narrative_store.current` are reads and are used.
|
||||
|
||||
**By vocabulary.** `test_no_state_event_type_was_added_for_media` requires that
|
||||
`events.ALLOWED` contains no name beginning `media` or containing `visual` or
|
||||
`asset`. There is no way for the media layer to speak in the story's language,
|
||||
so there is nothing for the validator to accept.
|
||||
|
||||
**By behaviour, which is the one that would catch a mistake nobody predicted.**
|
||||
Every test in `test_m10_authority.py` records the authoritative document —
|
||||
`narrative_state`, `head_branch_id`, `head_depth`, and the counts of state
|
||||
events, proposals and actions — before and after a media operation, and requires
|
||||
them to be **identical**:
|
||||
|
||||
| Operation | Result |
|
||||
| --- | --- |
|
||||
| Write a visual profile | authoritative document unchanged |
|
||||
| Update Alice's profile to "blue coat" | unchanged, and `"blue coat"` appears in no fact and in no entity |
|
||||
| Delete a profile | unchanged |
|
||||
| Build five scene packets | unchanged |
|
||||
| Build a packet with an explicit range | head does not move |
|
||||
| A dummy provider returns "Alice in a red coat in a corridor" | unchanged; neither string is anywhere in the state |
|
||||
| A provider raises `MediaProviderError` | unchanged; head does not advance |
|
||||
| Packet derivation raises inside `build` | unchanged, and the campaign still plays |
|
||||
| Delete every profile | campaign intact; packet still builds with `visual_profile: null` |
|
||||
| A profile naming an entity that does not exist | inert; packet unaffected; play continues |
|
||||
|
||||
### C.2 The claim in the other direction
|
||||
|
||||
§35 and §37 of the contract — a depiction never becomes canon, and promoting a
|
||||
visual detail into canon must be a deliberate act by the reader. M10 makes that
|
||||
structural: there is no code path from a `MediaResult` to a state event, because
|
||||
there is no code that consumes a `MediaResult` at all.
|
||||
|
||||
### C.3 Why a profile carries no branch coordinate
|
||||
|
||||
Every other derived record in the schema carries `(branch_id, depth)` because it
|
||||
describes a *moment*. A profile describes none: a character does not change
|
||||
appearance because the story forked. Per-position profiles would have been wrong
|
||||
twice — a reader who diverged would lose their cast's appearance, which is the
|
||||
opposite of the continuity a profile exists for, and a descriptor document would
|
||||
land in every per-position snapshot (measured: 245 copies of the same 367 bytes
|
||||
in a 120-turn campaign, §K).
|
||||
|
||||
---
|
||||
|
||||
## D. Schema and migration
|
||||
|
||||
**Tables added:** one.
|
||||
|
||||
```text
|
||||
visual_profiles
|
||||
id INTEGER PRIMARY KEY
|
||||
adventure_id INTEGER NOT NULL FK adventures(id) ON DELETE CASCADE, indexed
|
||||
entity_key VARCHAR(200) NOT NULL
|
||||
descriptors JSON
|
||||
features JSON
|
||||
style_notes TEXT
|
||||
created_at DATETIME
|
||||
updated_at DATETIME
|
||||
UNIQUE (adventure_id, entity_key) -- uq_visual_entity
|
||||
INDEX ix_visual_profiles_adventure_id
|
||||
```
|
||||
|
||||
**Columns added to existing tables:** none.
|
||||
**Columns changed or dropped:** none.
|
||||
**Story tables touched:** none.
|
||||
|
||||
**Migrations added: none.** `LATEST_VERSION` is **92**, exactly as M9 left it.
|
||||
|
||||
`create_all` builds a new table on every path — fresh install, existing database,
|
||||
test setup — as it did for `memories`, `branches`, `checkpoints`, `summaries` and
|
||||
the M7 knowledge tables, and it builds the index too, because the index is
|
||||
declared on the column rather than in `__table_args__`. Migration 92's own
|
||||
comment states this rule for the M7 tables; M10 follows it.
|
||||
|
||||
**Proof that none is needed**, and that the two paths converge
|
||||
(`test_m10_bundle.py`, §15 group):
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| An M9-era database (schema at 92, no `visual_profiles`, a campaign already in it) opened by this build | table present, empty, `ix_visual_profiles_adventure_id` present, version still 92 |
|
||||
| The campaign that was already there | title, action text and `PRAGMA foreign_key_check` unchanged |
|
||||
| Opening the same database three times | index set identical after each; no error; no accumulation |
|
||||
| Fresh install vs upgraded M9 file | `sqlite_master` DDL for `visual_profiles` **identical**; index sets **identical** |
|
||||
| `backup.create()` on the upgraded file | `integrity == "ok"`, `quick_check` ok, `foreign_key_check` empty, opens independently, carries the new table and the pre-M10 campaign, `user_version` 92 |
|
||||
| A profile written after the upgrade, then backed up | present in the backup with its descriptors |
|
||||
|
||||
**A defect this found — see §M.1.** M10 first shipped migration 93 creating
|
||||
`ix_visual_profiles_adventure`. Because `create_all` had already built
|
||||
`ix_visual_profiles_adventure_id`, an *upgraded* database ended up with both and
|
||||
a fresh install with one. The fresh-versus-upgraded comparison caught it; the
|
||||
migration was removed rather than renamed, because the right number of
|
||||
migrations here is zero.
|
||||
|
||||
---
|
||||
|
||||
## E. Bundle implications
|
||||
|
||||
**Did M9's v3 bundle change?** Yes — it gained one optional key:
|
||||
|
||||
```json
|
||||
"visualProfiles": [
|
||||
{"entityKey": "alice",
|
||||
"descriptors": {"build": "tall", "hair": "short black", "clothing": "grey blazer"},
|
||||
"features": ["tortoiseshell glasses"],
|
||||
"styleNotes": "photographic, natural light",
|
||||
"createdAt": "2026-09-07T…"}
|
||||
]
|
||||
```
|
||||
|
||||
**Did the format version change?** **No. It stays `ai-dnd-adventure-v3`.**
|
||||
|
||||
**Why.** M9 introduced a version because a v2 file with no prompt provenance was
|
||||
ambiguous between "written before M9" and "written by M9 from a campaign that
|
||||
had none". The test is therefore not "did the format gain a key" but *does
|
||||
omission create ambiguity about what an older file could have recorded*. It does
|
||||
not: a campaign with no visual profiles is the ordinary case — appearance is
|
||||
something a reader adds, not something a campaign has by default — so an absent
|
||||
key unambiguously means "none", exactly as `checkpoints` did before M4 and
|
||||
`knowledge` before M7. Bumping to v4 for a key whose absence is unambiguous would
|
||||
spend the mechanism M9 built and make it mean less next time.
|
||||
|
||||
**Legacy import behaviour.** Unchanged, and re-checked:
|
||||
|
||||
| File | Result |
|
||||
| --- | --- |
|
||||
| A v3 file with `visualProfiles` deleted (an M9-written file) | imports; campaign intact; zero profiles |
|
||||
| v2, v1 | unchanged — M9's readers are untouched |
|
||||
| A malformed profile in an otherwise good file | **dropped, campaign still imports.** A story that would not import because a description of somebody's coat is malformed would be the wrong trade |
|
||||
| A profile for an entity that no longer exists | imported and inert |
|
||||
|
||||
**Round trip.** Export → import → export produces the same profiles, in the same
|
||||
shape (`test_a_profile_survives_a_second_round_trip_unchanged`). The copy's
|
||||
profiles are its own rows — editing the copy does not reach the original — and
|
||||
the copy's scene packet is populated from them, which is the point of carrying
|
||||
them at all. A neighbouring campaign's profiles do not travel. The planner
|
||||
(`bundle.plan`, M9's before-anything-is-written checkpoint) checks profiles
|
||||
there rather than partway through a write.
|
||||
|
||||
**Clean-directory round trip, with profiles, across two real processes.**
|
||||
`test_profiles_reach_a_clean_data_directory_on_another_machine` follows M9's
|
||||
shape — two directories, two databases, two server processes, nothing crossing
|
||||
but the file, and machine B's database a file that never existed before, so its
|
||||
migrations run from nothing. Machine A profiles Alice and the office and exports;
|
||||
machine B imports and builds a scene packet whose `action_summary` matches,
|
||||
whose Alice carries her descriptors, whose office carries its lighting, and whose
|
||||
Roger is still `visual_profile: null`. Only the campaign id differs, which is
|
||||
what a new machine's id space means.
|
||||
|
||||
This exists because M9's own `test_m9_clean_import.py` predates visual profiles
|
||||
and carries none — it passes unchanged (§L), but it could not have caught a
|
||||
profile that failed to cross. M10 added no cross-machine coupling: `entity_key`
|
||||
is a key inside the campaign's own state document, which travels in the same
|
||||
file, so unlike a branch number or a knowledge source id it needs no translation
|
||||
on import.
|
||||
|
||||
---
|
||||
|
||||
## F. Acceptance matrix
|
||||
|
||||
| Test | Verdict | Evidence |
|
||||
| --- | --- | --- |
|
||||
| **K01 — Scene Snapshot Exists** | **PASS** *(and was already passing)* | `narrative_state["scene"]` since M5; normalized packet at `GET /api/adventures/{id}/scene-packet`. `test_m10_media_hooks.py` (contents, bounds, identity), `test_m10_lineage.py` (position correctness through every history operation and two process restarts). |
|
||||
| **K02 — Visual Character Profile** | **PASS** | `visual_profiles` + four endpoints (list, read, write, delete). Optional is tested, not just stated: Roger is deliberately unprofiled and the packet reports `visual_profile: null` rather than an empty profile. Stability is tested per operation — Undo and divergence, Redo and Save Point restore, a genuine process restart, and a two-process move to a clean data directory. |
|
||||
| **K03 — Visual Location Profile** | **PASS** | Same table and same code path — a location is an entity with a `type`. `the office` carries a profile; the packet's `location.visual_profile` returns it. |
|
||||
| **K04 — Attach Media Asset to Scene** | **PASS on the deferred branch** | Media tables are deliberately deferred, so this is reported against the acceptance text's own second clause ("if media tables are deferred: architecture/types should demonstrate equivalent extension point"). A test registers a dummy provider, builds a packet, generates a fake PNG carrying the packet's `scene_id` as provenance, and shows the story model byte-for-byte unchanged. **It is not PASS on the first clause**, and a reviewer who requires physical media tables in v1 should read this as PARTIAL. |
|
||||
|
||||
**K04's exact status, stated plainly.** Physically implementing `media_jobs` and
|
||||
`media_assets` now would mean designing a queue with no producer and no consumer,
|
||||
whose shape would be decided by a provider nobody has chosen; the codebase
|
||||
declined the same thing once already (M6's `derived_status`, commented "not a job
|
||||
queue"). What is guaranteed instead is the part that would be expensive to
|
||||
retrofit: a scene identity that is *derived* from campaign and position, so a
|
||||
future asset can reference a scene without a scenes table existing to reference.
|
||||
|
||||
---
|
||||
|
||||
## G. History and lineage evidence
|
||||
|
||||
`test_m10_lineage.py` — 8 tests, all passing, including the brief's own §4
|
||||
example and its §17 sequence, and two **genuine spawned-process restarts**
|
||||
(reusing `test_process_restart.Server`, so the process really goes away).
|
||||
|
||||
| Operation | What was checked | Result |
|
||||
| --- | --- | --- |
|
||||
| Set a scene, continue | packet describes the scene's position, not the head's | pass |
|
||||
| **Undo** | packet follows the state back; a scene set after the undone point is gone from it | pass |
|
||||
| **Redo** | packet returns to the later scene, with the same `scene_id` it had before | pass |
|
||||
| **Retry** | the take that is live decides the scene; the superseded take's scene does not leak | pass |
|
||||
| **Divergence** | Path A's scene and Path B's scene are different packets with different ids; both retained; the abandoned one is not current | pass |
|
||||
| **Save Point restore** | packet matches the position the Save Point names | pass |
|
||||
| **Redo, and a Save Point restore** | the profile is untouched by either; the scene set after the Save Point is correctly gone from the packet while the profile remains — which is the difference between story state and presentation metadata | pass |
|
||||
| **Restart (real process)** | the same position yields the same `scene_id` and the same packet contents in a new process | pass |
|
||||
| **Restart after divergence (real process)** | the campaign reopens on the branch it was left on, and the packet is that branch's | pass |
|
||||
|
||||
The mechanism behind all of it is M5's, not M10's: the head move restores the
|
||||
whole state document and the scene is part of it. What M10 adds is the test that
|
||||
pins it for the media seam, plus the derived identity that makes the restart
|
||||
comparison meaningful — a stored id would have been trivially stable and would
|
||||
have proved nothing.
|
||||
|
||||
---
|
||||
|
||||
## H. Hidden-information evidence
|
||||
|
||||
The test uses a **hidden M7 knowledge source**, because that is the product's
|
||||
real narrator-only mechanism, rather than an invented marker. Each check carries
|
||||
a **positive control**, so a pass cannot be a campaign where the secret was never
|
||||
established.
|
||||
|
||||
**The sentinel.** `ZARQUON-CONCEALED-OBSERVER-7731`, in a hidden Canon source
|
||||
describing a concealed observer behind the office's north wall.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| **Control:** does the narrator actually receive it? | **Yes** — the sentinel appears in the assembled prompt for a turn about the north wall panelling |
|
||||
| Does it reach the Scene Packet? | **No** — neither the sentinel nor "concealed observer" is anywhere in the packet |
|
||||
| **Control:** does a *visible* reference source reach the narrator? | **Yes** — the handbook appears in the context report's used-knowledge list |
|
||||
| Does that visible source reach the packet? | **No** — imported knowledge is excluded as a class, which is what makes the rule hold for a secret nobody thought to mark |
|
||||
| Do memories and summaries reach the packet? | **No** — after eight turns and a summary pass, the recurring "printer incident" text is absent |
|
||||
| Does something the *story* established reach the packet? | **Yes**, and it should — once a validated `set_scene` event puts the observer in the room, the observer is in the packet. It is no longer narrator-only knowledge; it is something that happened. The sentinel is still absent, because the story never said it. |
|
||||
|
||||
That last row is the reason the boundary is drawn where it is: a packet that hid
|
||||
established story from a depiction would be hiding the story from itself.
|
||||
|
||||
---
|
||||
|
||||
## I. No-media operation
|
||||
|
||||
`test_m10_no_media.py` — 12 tests, all passing. §20's list, run in one campaign
|
||||
with an empty provider registry:
|
||||
|
||||
```text
|
||||
several story turns ✓ Undo ✓
|
||||
state extraction ✓ Redo ✓
|
||||
memory + summary activity ✓ Retry ✓
|
||||
knowledge retrieval ✓ Save Point restore ✓
|
||||
restart ✓ *
|
||||
```
|
||||
|
||||
\* In this suite the restart is a fresh session reading what was written, not a
|
||||
new process. The **genuine spawned-process restarts are in
|
||||
`test_m10_lineage.py`** (§G), where they carry more weight: they run with
|
||||
profiles written and packets built, and check the derived `scene_id` is the same
|
||||
in a process that never saw the first one.
|
||||
|
||||
with **no** media warning, **no** media connection attempt, **no**
|
||||
missing-provider error, and no media schema requirement reaching narration.
|
||||
|
||||
Also checked:
|
||||
|
||||
- **The prompt is unchanged.** No context section labelled `media`,
|
||||
`scene_packet` or `visual_profile*` exists; the strings `visual_profile` and
|
||||
`scene_id` appear nowhere in the assembled system or story prompt.
|
||||
- **Ordinary play writes no media row.**
|
||||
- **No media setting exists** — neither in the settings API response nor as a
|
||||
column on `Settings`. A setting that exists is a setting that can be pointed at
|
||||
a cloud by mistake.
|
||||
- **The turn path cannot reach the media package**, checked by parsing imports
|
||||
rather than by grepping text.
|
||||
- The app serves, plays and passes health checks with `providers.registered() == {}`.
|
||||
|
||||
---
|
||||
|
||||
## J. Security and locality
|
||||
|
||||
**Outbound destinations added: none.** No module under `app/media/` references
|
||||
`httpx`, `requests`, `urllib.request`, `socket`, `aiohttp` or `subprocess`, and
|
||||
that is a test, not an inspection. No provider adapter ships, so there is nothing
|
||||
to connect *to*; the registry is empty at import and stays empty.
|
||||
|
||||
**Endpoint architecture — stricter than narration.**
|
||||
`providers.endpoint_rejection_reason(url)` applies `endpoints.rejection_reason`
|
||||
first (the shared policy: every address the hostname resolves to must be
|
||||
loopback, RFC1918, link-local, IPv6 ULA or CGNAT; known cloud inference hosts are
|
||||
refused by name; the check is on the **resolved address**, so
|
||||
`localhost.evil.example` does not pass) and **then requires loopback in
|
||||
addition**. Verified:
|
||||
|
||||
| Endpoint | Verdict |
|
||||
| --- | --- |
|
||||
| `http://127.0.0.1:8188/` | allowed |
|
||||
| `http://192.168.x.x:8188/` (trusted LAN — allowed for *narration*) | **refused** for media |
|
||||
| `https://api.openai.com/v1`, `https://replicate.com`, `http://8.8.8.8:8188`, `http://example.com` | refused |
|
||||
|
||||
This is deliberately narrower than `SECURITY-THREAT-MODEL.md` §73 permits, and
|
||||
§42A now records the discrepancy rather than leaving it to be found. The
|
||||
reasoning: a picture of a scene carries the scene with it, and a GPU rendering
|
||||
someone's campaign is a machine that person is sitting at.
|
||||
|
||||
**No TLS verification bypass** was introduced. There is no `verify=False`, no
|
||||
`-k`, and no new HTTP client at all; `tlstrust.py` is untouched and
|
||||
`test_tls_trust.py` passes.
|
||||
|
||||
**Filesystem/media exposure: none.** No asset is stored, no directory is served,
|
||||
no path comes from a caller. M10 adds no file-serving route.
|
||||
|
||||
**CSP/CORS: unchanged.** No frontend file was modified, no new origin is
|
||||
contacted, and `test_offline_assets.py` (which reads the built SPA and the CSP)
|
||||
passes.
|
||||
|
||||
**Cloud dependency: none.** Nothing was installed to demonstrate an interface —
|
||||
no ComfyUI, no diffusers, no Whisper, no Kokoro, no model download. The
|
||||
`requirements.txt` diff is empty.
|
||||
|
||||
**One new disclosure boundary, and it is not a network one.** The Scene Packet is
|
||||
the input a future provider would receive, so its contents are a disclosure
|
||||
decision — covered in §H.
|
||||
|
||||
---
|
||||
|
||||
## K. Performance and storage
|
||||
|
||||
Measured with `backend/tools/m10_media_cost.py`, a 120-turn campaign played
|
||||
through the real turn engine and the real state pipeline, with SQL statements
|
||||
counted by `tools/dbmeter.py`.
|
||||
|
||||
```text
|
||||
120 turns, 245 action rows
|
||||
|
||||
scene records M10 wrote
|
||||
visual_profiles rows 2 one per profiled entity, written once
|
||||
scene rows 0 M10 adds no scenes table
|
||||
M5 per-position state snapshots 245 already there; the scene lives here
|
||||
|
||||
bytes added to the database
|
||||
empty database 188416 B
|
||||
after the fixture campaign 196608 B
|
||||
after 120 more turns 1056768 B
|
||||
visual profile content 367 B 0.035% of the database
|
||||
|
||||
profile duplication: campaign-scoped against per-position
|
||||
as stored, once per entity 367 B
|
||||
if snapshotted per position 89915 B x245
|
||||
|
||||
packet: persisted or constructed
|
||||
rows written while building one 0
|
||||
packet rows in any table 0 built on read, never stored
|
||||
build time, 2 turns 16.6 ms
|
||||
build time, 122 turns 12.6 ms
|
||||
|
||||
current-scene query behaviour
|
||||
statements, 2 turns 5
|
||||
statements, 122 turns 4
|
||||
does not grow with the campaign
|
||||
```
|
||||
|
||||
The packet's four statements at turn 122 are: the adventure, its visual
|
||||
profiles, the current user, and the head's branch. **Nothing walks the
|
||||
transcript**, which is the property that matters — a scene derivation that
|
||||
scanned actions would have made every future depiction O(turns).
|
||||
|
||||
The `x245` row is the measurement behind §C.3: per-position profiles would have
|
||||
stored the same 367 bytes 245 times in this campaign to say something that never
|
||||
varies.
|
||||
|
||||
**Storage added to an ordinary campaign that uses no profiles: one empty table.**
|
||||
|
||||
---
|
||||
|
||||
## L. Regression counts
|
||||
|
||||
All runs on this branch, after every M10 change, on 2026-09-07.
|
||||
|
||||
| Suite | Result | Time |
|
||||
| --- | --- | --- |
|
||||
| **Backend, full** | **1,191 passed, 14 skipped, 0 failed** — 1,205 collected, exit 0 | 862.9 s |
|
||||
| **M10-specific** | **89 passed** (39 hooks + 8 lineage + 12 authority + 12 no-media + 18 bundle/migration) | 56.4 s |
|
||||
| **Frontend component suite** | **145 passed**, 12 files, 0 failed | 8.17 s |
|
||||
| **Lint** (`oxlint`) | **0 errors**, 15 warnings, exit 0 | — |
|
||||
| **Production build** (`vite build`) | clean — `index-DcHbz7ga.js` 389.66 kB (gzip 119.25 kB), `index-B55Q8MSM.css` 47.50 kB | 0.58 s |
|
||||
| **Docker** (`--no-cache`) | **exit 0**; image runs and imports `app.media` with `registered() == {}` | 25.2 s |
|
||||
| **Browser** | **not run — M10 adds no reader-facing surface.** See §L.2 |
|
||||
|
||||
The 14 skips are M9's and are unchanged — confirmed by running the five files
|
||||
that carry a skip condition on their own (26 passed, 14 skipped): seven need a
|
||||
second machine or an environment the suite cannot create; the rest need a real
|
||||
local model.
|
||||
|
||||
The 15 lint warnings are pre-existing (`no-unused-vars` in two test files, and
|
||||
`react/only-export-components` in components that export a constant beside a
|
||||
component). **M10 modified no frontend file**, so the count is M9's, unchanged.
|
||||
|
||||
### L.1 By group
|
||||
|
||||
Each group was run on its own, so a reviewer can check a claim without running
|
||||
the whole suite. Every group is green.
|
||||
|
||||
| Group | Files | Result |
|
||||
| --- | --- | --- |
|
||||
| **M10** | `test_m10_media_hooks` (39), `_lineage` (8), `_authority` (12), `_no_media` (12), `_bundle` (18) | **89 passed** — 56.4 s |
|
||||
| **M9 recovery** | `test_m9_backup`, `_clean_import`, `_corrupt_bundles`, `_legacy_bundles`, `_portability`, `test_bundle_v2` | **173 passed** — 371.3 s |
|
||||
| **Migration** | `test_tree_migration`, `test_knowledge_migration`, `test_pre_m5_compatibility`, `test_snapshot_compression` | **50 passed** — 27.8 s |
|
||||
| **M5/M6/M7 state, memory, knowledge** | `test_narrative_state`, `test_worldstate_integration`, `test_memory_nodes`, `test_memory_retrieval`, `test_context_memory`, `test_imported_knowledge`, `test_knowledge_retrieval_quality` | **216 passed** — 113.7 s |
|
||||
| **M3/M4 history** | `test_story_tree_baseline`, `test_branch_forking`, `test_head_cursor`, `test_save_points`, `test_take_state`, `test_process_restart` | **137 passed** — 139.6 s |
|
||||
| **Security / offline** | `test_egress`, `test_endpoint_policy`, `test_local_only_surface`, `test_offline_assets`, `test_tls_trust` | **95 passed** — 18.4 s |
|
||||
|
||||
The security group is the one that carries §J's claims: no outbound route, the
|
||||
endpoint policy, the local-only API surface, the offline asset and CSP checks,
|
||||
and the TLS trust union. All were green before M10 and are green now.
|
||||
|
||||
### L.2 On the browser run
|
||||
|
||||
M10 adds **no reader-facing surface**: no page, no control, no copy, no route in
|
||||
the SPA. `git status` shows no file under `frontend/` modified. The five new
|
||||
endpoints are backend-only and nothing in the browser calls them. A real-browser
|
||||
regression pass would therefore be re-verifying M8/M9's surfaces against a build
|
||||
identical to theirs, and its evidence would be M9's evidence. The frontend
|
||||
component suite and the production build were run anyway, and are green.
|
||||
|
||||
This is stated as a decision, not an omission: **if the reviewer wants a browser
|
||||
pass as a matter of process, it has not been done.**
|
||||
|
||||
---
|
||||
|
||||
## M. Findings
|
||||
|
||||
### M.1 A redundant index migration made two databases disagree — **introduced by M10, fixed here**
|
||||
|
||||
**Severity:** low in effect, moderate in kind. **Blocker:** no — fixed.
|
||||
**Owner:** M10 (closed).
|
||||
|
||||
M10 first added migration 93, `CREATE INDEX IF NOT EXISTS ix_visual_profiles_adventure
|
||||
ON visual_profiles (adventure_id)`. But `VisualProfile.adventure_id` declares
|
||||
`index=True`, so `create_all` already builds `ix_visual_profiles_adventure_id` —
|
||||
on a fresh install *and* on an existing database, since `create_all` runs before
|
||||
the migration loop. The result:
|
||||
|
||||
```text
|
||||
upgraded from 92: ix_visual_profiles_adventure, ix_visual_profiles_adventure_id
|
||||
fresh install: ix_visual_profiles_adventure_id
|
||||
```
|
||||
|
||||
Two schemas differing by which path the file took, which is the thing a migration
|
||||
exists to prevent, plus a redundant index on every upgraded database.
|
||||
|
||||
**Found by** `test_a_fresh_database_arrives_at_the_same_place`, which compares a
|
||||
fresh schema against an upgraded one. Neither database examined on its own would
|
||||
have shown it. **Fixed by removing the migration**, not by renaming the index:
|
||||
migration 92's own comment already records the rule for the M7 tables — when the
|
||||
index is declared on the column there is nothing left for a `CREATE INDEX` to do.
|
||||
`LATEST_VERSION` returns to 92.
|
||||
|
||||
### M.2 The state model refuses an over-large scene, and a test asked for one — **not a defect**
|
||||
|
||||
While writing the packet's bounds test I sent 43 entries in `set_scene`'s
|
||||
`present`, exceeding `validate.MAX_LABELS = 40`. The event was correctly refused
|
||||
and the previous scene stayed, so the test measured the wrong scene and failed.
|
||||
Recorded because the diagnosis matters: the product was right and the test was
|
||||
wrong. The test now uses 33 and asserts its own precondition, so it cannot
|
||||
silently measure a scene it did not set.
|
||||
|
||||
### M.3 The Story Engine vocabulary grep hit the file that forbids the vocabulary — **test defect, fixed**
|
||||
|
||||
§9's rule is that no provider vocabulary (ComfyUI, Whisper, `num_inference_steps`,
|
||||
LoRA) appears in the Story Engine. The first version of the test grepped the
|
||||
whole backend and hit `providers.py`, whose docstrings *name* those things
|
||||
precisely in order to exclude them. Fixed by scoping the grep to the story
|
||||
engine, and by adding a complementary test that checks the seam **by behaviour**:
|
||||
no provider registered, and no networking import anywhere under `app/media/`.
|
||||
|
||||
### M.3a A text search for "media" matched "im**media**tely" — **test defect, fixed**
|
||||
|
||||
The first version of the no-media import check read each turn-path module and
|
||||
required the string `media` to be absent. `narrative/store.py` contains the word
|
||||
*immediately*, so the test failed on a module that imports nothing. It now parses
|
||||
the file and inspects its **import statements**, which is what the claim was
|
||||
always about. Recorded because the failure looked briefly like a real coupling
|
||||
and was not, and because the fixed version is the stronger test: a module could
|
||||
have imported the package while never spelling the word in prose.
|
||||
|
||||
### M.4 `ambience` is present and empty — **known gap, deliberate**
|
||||
|
||||
**Severity:** low. **Blocker:** no. **Owner:** whichever milestone builds a
|
||||
coordinator.
|
||||
|
||||
The packet's `ambience` object has the right shape and no content, because
|
||||
filling it would mean extending the `set_scene` event — a change on the prompt
|
||||
path, asking the narrator for something new, which is outside M10's scope. A
|
||||
future provider gets a stable shape today and content when someone decides the
|
||||
narrator should be asked.
|
||||
|
||||
### M.5 Deleting profiles is not recoverable from within the app — **known limit, stated**
|
||||
|
||||
**Severity:** low. **Blocker:** no.
|
||||
|
||||
M9's rebuildable data can be regenerated; a visual profile cannot, because it is
|
||||
something a reader wrote. It travels in the bundle, so a backup or an export
|
||||
recovers it, and deletion is per-entity and explicit. There is no undo for it,
|
||||
and none was invented — that would be a second history model beside the story's.
|
||||
|
||||
**No pre-existing defect was found in M9's or earlier work during this
|
||||
milestone.** The full backend suite was green before M10 began and is green now.
|
||||
|
||||
---
|
||||
|
||||
## N. Planning changes
|
||||
|
||||
| Document | Change | Why |
|
||||
| --- | --- | --- |
|
||||
| `planning/DATA-MODEL.md` | **New §28A** — media extension points as implemented | §20/§27/§28 describe a scene table and job/asset tables. Only one of the three exists, and a reader of the conceptual model needs to know which, and why the scene is derived from §20's own data rather than stored beside it. |
|
||||
| `planning/TECHNICAL-DESIGN.md` | **New §15.1** under Scene and Future Media Boundary | §15 said "persist or derive". The answer is *derive*, and the reason (M5 already persisted it) is the milestone's central fact. Also records the packet's exclusions, the STT asymmetry and the endpoint policy. |
|
||||
| `planning/MEDIA-EXTENSION-CONTRACT.md` | **New §90**, appended | The contract is Phase 0B design and stays readable as such. §90 records what was built, the three places implementation answered an open question (§5 already satisfied, §12 drawn wider, §7-9 collapsed into one table), and what is deliberately unbuilt. |
|
||||
| `planning/BUILD-MILESTONES.md` | **M10 status block** | Milestone status, the shaping finding, what shipped, and the defect its own tests caught. The four post-M8 playtest findings above it are untouched and still M11's. |
|
||||
| `planning/V1-ACCEPTANCE-TESTS.md` | **K01-K04 results** | Acceptance evidence. K01 records that it was already passing; K04 records which of its two clauses it passes on. |
|
||||
| `planning/SECURITY-THREAT-MODEL.md` | **New §42A** | The trust boundary did not widen, but in one place the implementation is deliberately **narrower** than §73 permits. A stricter implementation than the model describes is still a discrepancy, and an undocumented one becomes an accidental relaxation later. |
|
||||
| `planning/VERSION.md` | **v3.6 entry** | Records this milestone's documentation changes and the two decisions (no format bump, no migration). |
|
||||
| `planning/README.md` | Status, milestone map, reading order, report rotation | M10 is implemented; M11 is next. Records **why M9's report stays in `reports/`** against the usual rotation: M9 is not accepted, and M10's baseline is M9's. |
|
||||
| `README.md` | `media/` in the architecture map; `VisualProfile` in the model list; test count 920 → 1,191 | The map is the first thing a new reader reads. |
|
||||
| `DEVELOPMENT.md` | The stricter future media endpoint rule; a note that the suite takes ~15 minutes | Whoever adds the first provider should find the rule before writing the adapter. |
|
||||
|
||||
No planning document was rewritten, and no earlier milestone's evidence was
|
||||
edited.
|
||||
|
||||
---
|
||||
|
||||
## O. M11 handoff
|
||||
|
||||
### O.1 M10's residual risk
|
||||
|
||||
1. **K04 is satisfied structurally, not physically.** No `media_jobs` or
|
||||
`media_assets` table exists. A future coordinator will design them, and the
|
||||
contracts here constrain that design only loosely. *Risk: low — the expensive
|
||||
part (a stable scene identity) is fixed; the cheap part (two tables) is not.*
|
||||
2. **`ambience` is an empty shape** (§M.4). Filling it means extending
|
||||
`set_scene`, which changes what the narrator is asked for. *Risk: low; a
|
||||
provider adapter written today would find the fields and no values.*
|
||||
3. **The seam has no consumer, so it is unexercised by real use.** Every test
|
||||
here uses a dummy provider. The contracts are shaped by the contract document
|
||||
and by what the state model can supply, not by an adapter that had to work
|
||||
against a real generator. *Risk: moderate for the interfaces' ergonomics, nil
|
||||
for the story engine — the first real adapter may want the packet reshaped,
|
||||
and nothing in the story depends on its shape.*
|
||||
4. **A visual profile cannot be recovered from within the app** (§M.5).
|
||||
5. **Profiles are not surfaced to the reader at all.** They are API-only. Whoever
|
||||
builds a media UI owns the browser surface, and no reader-facing vocabulary
|
||||
for them has been invented — deliberately, since M10 was told not to introduce
|
||||
reader-facing branding or surfaces.
|
||||
|
||||
### O.2 M9 carry-forward still relevant
|
||||
|
||||
All six of M9's residual risks are unchanged by M10 — none was addressed and none
|
||||
was made worse:
|
||||
|
||||
| M9 residual | Status after M10 |
|
||||
| --- | --- |
|
||||
| Bundle ceiling ~279 turns | unchanged. M10 adds ~370 bytes per campaign to a file whose ceiling is set by per-position state; it does not move the number. |
|
||||
| `quick_check` rather than `integrity_check` | unchanged; re-exercised on a migrated database (§D) |
|
||||
| No scheduled backup | unchanged |
|
||||
| Stale `chunk_id` in a restored snapshot | unchanged |
|
||||
| Importing machine's context window may differ | unchanged; still M11's |
|
||||
| This machine cannot drive a file into/out of the browser | unchanged; still M11's, and still the cheapest fix is an unconfined Firefox or Xvfb |
|
||||
|
||||
M9's two items "carried to M10" are both **closed**: media tables were owned and
|
||||
the decision is recorded (§F, K04); the `scene` section of the state document was
|
||||
indeed the extension point, and is what the packet is built from.
|
||||
|
||||
### O.3 The post-M8 hands-on findings — still M11's, untouched
|
||||
|
||||
M10 neither implemented nor tested any of them, as its brief required. They are
|
||||
listed here so they cannot be lost when M9's report is eventually archived; the
|
||||
durable copy is in `BUILD-MILESTONES.md`.
|
||||
|
||||
| Finding | Owner |
|
||||
| --- | --- |
|
||||
| **A.** The browser tab still reads `AI D&D` | M11 release polish |
|
||||
| **B.** After Undo, the reader cannot tell where they are | M11 UX/release polish |
|
||||
| **C.** The narration-length setting has no measurable effect | M11 realistic-model behaviour |
|
||||
| **D.** Character identity / coreference confusion — root cause **unknown**, and the playtest database was destroyed | M11 realistic-model / context diagnostic |
|
||||
|
||||
**On D specifically:** M10's fixture is deliberately the same shape — a
|
||||
protagonist, two more characters in the room, and a fourth who is not — but
|
||||
**nothing in M10 asserts anything about coreference**, and the fixture's
|
||||
resemblance is not evidence about the finding. M11 still owes the explicit
|
||||
diagnostic. What M10 does contribute, incidentally, is that a visual profile
|
||||
attaches to the canonical `entity_key` rather than to a display name, so whatever
|
||||
M11 concludes about identity, profiles are keyed to the thing the state model
|
||||
considers one character.
|
||||
|
||||
### O.4 The context-window issue
|
||||
|
||||
Unchanged and still M11's: a deployment's enforced context window may be far
|
||||
below `Settings.context_token_budget` (Ollama defaults to 4,096 when it sees no
|
||||
VRAM). Documented in `DEVELOPMENT.md`; detection, the Settings warning question,
|
||||
and the 100-turn certification all remain open. M10 sends nothing to a model and
|
||||
does not touch it.
|
||||
|
||||
### O.5 Realistic-model / 100-turn work
|
||||
|
||||
Untouched by M10, and M10 adds no new realistic-model obligation: the media seam
|
||||
has no model in it. The 100-turn certification, the contrast/focus measurement,
|
||||
the WCAG audit and the streaming-import question all carry forward unchanged.
|
||||
|
||||
---
|
||||
|
||||
## P. Final verdict
|
||||
|
||||
**1. Is M10's Definition of Done satisfied?**
|
||||
|
||||
> *Future media providers can be added through defined local interfaces without
|
||||
> redesigning core story authority/history.*
|
||||
|
||||
**Yes.** A provider is added by implementing a `Protocol` and registering it;
|
||||
it receives a Scene Packet built from the authoritative state at a position, and
|
||||
returns a `MediaResult`. Nothing in story authority or history changes to
|
||||
accommodate it — proved by the fact that nothing in `app/media/` can even import
|
||||
the code that writes state, and that every media operation leaves the
|
||||
authoritative document byte-identical.
|
||||
|
||||
The honest qualification: **no real adapter has been written against these
|
||||
interfaces**, so their ergonomics are untested (§O.1.3). The story engine's
|
||||
independence, which is what the Definition of Done is actually about, is tested.
|
||||
|
||||
**2. Are K01-K03 all PASS?** **Yes** — all three, with evidence in §F and §G.
|
||||
K01 additionally records that it was already passing before M10 began.
|
||||
|
||||
**3. What is K04's exact status?** **PASS on the acceptance text's deferred
|
||||
branch** ("if media tables are deferred: architecture/types should demonstrate
|
||||
equivalent extension point"), demonstrated by a dummy provider producing an asset
|
||||
against a real packet with the story model unchanged. **Not PASS on the first
|
||||
branch**, since no media table is physically implemented. A reviewer who requires
|
||||
physical tables in v1 should read K04 as **PARTIAL**; the decision and its
|
||||
reasoning are in §F.
|
||||
|
||||
**4. Can all ordinary story operation run with zero media provider?** **Yes** —
|
||||
§I. Turns, state extraction, memory, summaries, knowledge retrieval, Undo, Redo,
|
||||
Retry and Save Point restore, with an empty registry, no media setting in
|
||||
existence, no warning, no connection attempt and no media row written. Restart is
|
||||
covered as a genuine spawned-process restart in the lineage suite (§G); the
|
||||
no-media suite's own restart step is a fresh session read, and §I says so.
|
||||
|
||||
**5. Can a future image provider be added without modifying story authority or
|
||||
history?** **Yes.** It implements `MediaProvider`, is handed a packet, and
|
||||
returns a result. No story table, event type, or history operation changes.
|
||||
|
||||
**6. Can a future video provider consume a multi-turn scene representation
|
||||
without redesigning history?** **Yes.** `packet.build(..., start=, end=)` takes a
|
||||
depth range on the branch and the resulting `scene_id` encodes it
|
||||
(`c7:b3:4-9`). The range is expressed in the coordinates history already uses, so
|
||||
a multi-turn packet is a read of existing structure rather than a new one.
|
||||
|
||||
**7. Does future STT feed editable draft input rather than authoritative state?**
|
||||
**Yes, structurally.** `TranscriptionProvider.transcribe` returns a
|
||||
`DraftTranscription` with `editable=True` and no commit method. A transcriber
|
||||
cannot submit; the ordinary authoritative path is the only way in.
|
||||
|
||||
**8. Is scene/media data branch-safe?** **Yes** — §G. The scene follows the
|
||||
active lineage through Undo, Redo, Retry, divergence, Save Point restore and two
|
||||
genuine process restarts, because it *is* the authoritative state rather than a
|
||||
copy of it. Profiles are campaign-scoped by design and are stable across all of
|
||||
those, which is the correct behaviour for appearance and is argued in §C.3.
|
||||
|
||||
**9. Did M10 create any network dependency?** **No.** No dependency added, no
|
||||
HTTP client, no socket, no subprocess, no provider adapter, no model download, no
|
||||
media endpoint setting. The endpoint *policy* that a future provider will meet is
|
||||
stricter than the one narration uses: loopback only.
|
||||
|
||||
**10. Is there any blocker before M11?** **No blocker.** Two things a reviewer
|
||||
should decide rather than inherit:
|
||||
|
||||
- whether **K04 on the deferred branch** is acceptable for v1, or whether media
|
||||
tables must be physically present (§F);
|
||||
- whether **no browser regression pass** is acceptable given that M10 modified no
|
||||
frontend file (§L.2).
|
||||
|
||||
Neither is a defect; both are decisions that belong to the reviewer.
|
||||
Reference in New Issue
Block a user