Enforce book planning scope boundaries

This commit is contained in:
Daniel Maddern 2026-08-29 00:58:22 +07:00
parent ca045c02d7
commit 8a6eab543f
7 changed files with 442 additions and 3 deletions

View file

@ -42,7 +42,10 @@ End with [[END_OF_SCENE]] on its own line."""
STANDALONE_SCENE_PLAN_SYSTEM = """You are a fiction scene architect. Return one valid JSON object only. STANDALONE_SCENE_PLAN_SYSTEM = """You are a fiction scene architect. Return one valid JSON object only.
Preserve every supplied canon fact and source boundary. Plan a complete dramatized scene, not a synopsis. Preserve every supplied canon fact and source boundary. Plan a complete dramatized scene, not a synopsis.
Do not invent authority for provisional or planning sources, and do not silently resolve contradictions.""" Do not invent authority for provisional or planning sources, and do not silently resolve contradictions.
When the cited context contains an APPROVED BOOK-STATE PLANNING PACKET, that packet is the hard narrative
scope boundary. Other source context may prevent contradictions but cannot authorize additional events,
agreements, state changes, explanations, or exact values."""
STANDALONE_SCENE_PLAN_TEMPLATE = """Plan one complete scene titled {title}. STANDALONE_SCENE_PLAN_TEMPLATE = """Plan one complete scene titled {title}.
@ -70,7 +73,10 @@ Return strict JSON in this shape:
"boundary_constraints":[],"continuity_questions":[]}} "boundary_constraints":[],"continuity_questions":[]}}
Use 3-8 concrete beats. Mark only indispensable events required:true. Preserve unresolved continuity questions Use 3-8 concrete beats. Mark only indispensable events required:true. Preserve unresolved continuity questions
instead of guessing. The ending state and final image must define where the scene stops.""" instead of guessing. The ending state and final image must define where the scene stops. For a book-bound
scene, repeat the approved chapter beats without expanding their events or state changes. Include an exact
value only when it appears explicitly in the approved chapter packet or scene brief; omit numbers and
classifications found only in background source context."""
SCENE_IDEA_TYPES = { SCENE_IDEA_TYPES = {
"quiet_connection": "A short, low-stakes character moment whose meaning comes from attention or choice.", "quiet_connection": "A short, low-stakes character moment whose meaning comes from attention or choice.",

View file

@ -642,6 +642,13 @@ class StandaloneSceneService:
) )
) )
plan = self._normalize_plan(extract_json_object(response.content), scene) plan = self._normalize_plan(extract_json_object(response.content), scene)
discarded_exact_values = []
discarded_proposed_beats = []
if scene.book_state_id:
plan, discarded_proposed_beats = self._apply_book_chapter_scope(scene, plan)
plan["exact_values"], discarded_exact_values = self._scoped_exact_values(
scene, plan["exact_values"]
)
requirements = self._requirements(plan) requirements = self._requirements(plan)
scene.plan = plan scene.plan = plan
scene.contract_requirements = requirements scene.contract_requirements = requirements
@ -654,12 +661,62 @@ class StandaloneSceneService:
"prompt_sha256": text_sha256(prompt), "prompt_sha256": text_sha256(prompt),
"response_sha256": text_sha256(response.content), "response_sha256": text_sha256(response.content),
"context_pack_sha256": pack["sha256"], "context_pack_sha256": pack["sha256"],
"discarded_out_of_scope_exact_values": discarded_exact_values,
"discarded_model_proposed_beats": discarded_proposed_beats,
**self._book_state_metadata(scene), **self._book_state_metadata(scene),
} }
scene.generation_metadata = metadata scene.generation_metadata = metadata
scene.save() scene.save()
return scene return scene
def _apply_book_chapter_scope(
self, scene: StandaloneScene, plan: dict[str, Any]
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
chapter, _chapters, _index = self._book_chapter(
scene.book_state.content or {}, scene.book_chapter_key
)
approved_beats = [
{
"text": str(item.get("text") or "").strip(),
"required": bool(item.get("required")),
}
for item in chapter.get("beats") or []
if isinstance(item, dict) and str(item.get("text") or "").strip()
]
approved_text = {item["text"].casefold() for item in approved_beats}
discarded = [
item
for item in plan["beats"]
if str(item.get("text") or "").strip().casefold() not in approved_text
]
plan["beats"] = approved_beats
plan["purpose"] = str(chapter.get("purpose") or "").strip()
plan["ending_state"] = str(chapter.get("ending_state") or "").strip()
return plan, discarded
def _scoped_exact_values(
self, scene: StandaloneScene, exact_values: list[Any]
) -> tuple[list[Any], list[Any]]:
scope = "\n".join(
[
self._book_planning_packet(scene),
scene.brief,
json.dumps(scene.constraints, ensure_ascii=False),
json.dumps(scene.forbidden_events, ensure_ascii=False),
json.dumps(scene.boundary_constraints, ensure_ascii=False),
]
).casefold()
allowed = []
discarded = []
for item in exact_values:
value = item.get("value") if isinstance(item, dict) else item
candidate = "" if value is None else str(value).strip().casefold()
if candidate and candidate in scope:
allowed.append(item)
else:
discarded.append(item)
return allowed, discarded
def prepare_context( def prepare_context(
self, self,
scene: StandaloneScene, scene: StandaloneScene,

View file

@ -0,0 +1,139 @@
# The Fortune Below Bootstrap
This bootstrap registers source evidence only. It does not extract facts or promote generated prose into canon.
## 1. Apply Artifex Migrations
Use the configured PostgreSQL database so scene state and model-request provenance survive across processes.
```bash
python manage.py migrate
```
## 2. Register Shared Hard Canon
Shared canon belongs to a series-reference work. Book scene retrieval automatically includes this work.
```bash
python manage.py story_sources register \
--root S:/PycharmProjects/wraith-book-projects/AuthorCompanion/manuscripts/the-fortune-below \
--series-slug labyrinth-hero \
--series-title "Labyrinth Hero" \
--work-slug fortune-below-series-reference \
--work-title "Fortune Below Series Reference" \
--work-type series_reference \
--authority canon \
--document-type canon \
--include-glob "canon/fortune-below-world-lore.md"
```
`fortune-below-restructure-canon.md` and `fortune-below-series-arc.md` identify themselves as planning
documents. Archive them under `fortune-below-book-two-legacy` with `planning` authority; never register them
as shared canon.
## 3. Register Mainline Book Two Planning
Planning documents are retrievable only when a scene command explicitly includes `planning` authority. Do
not use a broad `book-two/planning/*.md` glob: that directory also contains Sabine/Corin side-novel material.
```bash
python manage.py story_sources register \
--root S:/PycharmProjects/wraith-book-projects/AuthorCompanion/manuscripts/the-fortune-below \
--series-slug labyrinth-hero \
--series-title "Labyrinth Hero" \
--work-slug the-fortune-below \
--work-title "The Fortune Below" \
--authority planning \
--document-type planning \
--include-glob "book-two/planning/fortune-below-book-two-cerys-*.md" \
--include-glob "book-two/planning/fortune-below-book-two-corins-*.md" \
--include-glob "book-two/planning/fortune-below-book-two-male-friends-*.md" \
--include-glob "book-two/planning/fortune-below-book-two-missing-scene-roadmap.md" \
--include-glob "book-two/planning/fortune-below-book-two-permission-slips-*.md"
```
Register the current folded-route plan and approved pilot artifacts separately from the Artifex artifact root.
## 4. Register The Sabine/Corin Side Novel
The side novel is a distinct work even though its chronology overlaps Books Two and Three.
```bash
python manage.py story_sources register \
--root S:/PycharmProjects/wraith-book-projects/AuthorCompanion/manuscripts/the-fortune-below \
--series-slug labyrinth-hero \
--series-title "Labyrinth Hero" \
--work-slug fortune-below-sabine-corin \
--work-title "Fortune Below: Sabine and Corin" \
--authority planning \
--document-type planning \
--include-glob "book-two/planning/*sabine-corin*.md"
```
Register selected Sabine/Corin scenes and manuscript drafts against this side work, never against
`the-fortune-below`.
## 5. Archive Existing Mixed Book Two Material
The old planning and scene directories mix mainline, side-novel, alternate, and superseded structures. Preserve
the complete directories under the `fortune-below-book-two-legacy` work before selecting any source into a
current work. The legacy work is `other`, so its documents are not visible to mainline or side-novel retrieval.
Pure Sabine/Corin sources may then be registered separately under `fortune-below-sabine-corin`. Do not leave
active copies attached to `the-fortune-below`.
## 6. Register Existing Mainline Scenes Safely
Existing mainline scenes enter as provisional evidence. Do not use a broad `book-two/scenes/*.md` glob because
that directory contains side-novel drafts and noncanon experiments.
```bash
python manage.py story_sources register \
--root S:/PycharmProjects/wraith-book-projects/AuthorCompanion/manuscripts/the-fortune-below \
--series-slug labyrinth-hero \
--series-title "Labyrinth Hero" \
--work-slug the-fortune-below \
--work-title "The Fortune Below" \
--authority provisional \
--document-type scene \
--include-glob "book-two/scenes/fortune-below-book-two-cerys-*.md" \
--include-glob "book-two/scenes/fortune-below-book-two-selka-cerys-*.md" \
--include-glob "book-two/scenes/fortune-below-book-two-estate-*.md" \
--include-glob "book-two/scenes/fortune-below-book-two-short-ch01-*.md"
```
Register the ten-turn manuscript under the side work as provisional manuscript material until its assembly is
approved.
```bash
python manage.py story_sources register \
--root S:/PycharmProjects/wraith-book-projects/AuthorCompanion/manuscripts/the-fortune-below \
--series-slug labyrinth-hero \
--series-title "Labyrinth Hero" \
--work-slug fortune-below-sabine-corin \
--work-title "Fortune Below: Sabine and Corin" \
--authority provisional \
--document-type manuscript \
--include-glob "book-two/ten-turn-manuscript/*.md"
```
## 7. Plan A Scene
The default context includes hard canon only. This example deliberately includes planning material and pins the
two current rule documents.
```bash
python manage.py fiction_scene run \
--series-slug labyrinth-hero \
--work-slug fortune-below-sabine-corin \
--title "New Scene Title" \
--brief S:/path/to/new-scene-brief.md \
--target-words 2200 \
--include-authority canon \
--include-authority planning \
--pin-document book-two/planning/fortune-below-sabine-corin-turn-and-household-rules.md \
--pin-document book-two/planning/fortune-below-sabine-corin-physical-continuity-reconciliation.md
```
Inspect the returned plan before approving it. Final scene approval registers the artifact as `provisional`, not
`canon`. Mainline scene commands must not pin side-work documents.

View file

@ -0,0 +1,50 @@
# Book Two Mainline Fact Ledger
## Authority
This ledger separates mainline planning facts from the overlapping Sabine/Corin side novel. The approved
two-chapter book state is authoritative only for its pilot run. Everything beyond Chapter 2 remains
provisional until approved in a later book-state version.
## Approved Pilot Scope
### Chapter 1: Two Markers
- Selka accepts a survey under an independently terminable contract.
- Selka retains ownership of her maps.
- Records appear to place the same damaged marker in two incompatible locations.
- Selka refuses to certify an explanation before controlled field testing.
- The doubled-marker contradiction remains unresolved at the chapter ending.
### Chapter 2: What The Profit Means
- The bank confirms the million-crown windfall.
- Corin proposes protected allocations; the allocations are not yet accomplished facts.
- Cerys recognizes that the proposal could make legal freedom financially possible.
- Cerys asks whether freedom can coexist with her chosen home, work, and belonging.
- Cerys is not released in the pilot.
### Pilot Exclusions
- Do not resolve the folded-route anomaly.
- Do not establish additional financial, contractual, legal, or route facts merely because they appear in
legacy planning.
- Do not introduce the Sabine/Corin game.
## Provisional Mainline Scope After Chapter 2
The current folded-route restructure proposes, but has not canonized:
- Selka's investigation as Book Two's external spine.
- Cerys's meaningful freedom as the emotional co-spine.
- Corin learning that money and equipment cannot replace another person's authority.
- Sabine's later rescue, prompt release, independent alternatives, chosen return, and paid estate work.
- Short externally observable Sabine/Corin background beats after Sabine is free.
- The folded-route crisis as the mainline climax.
- Hallow Street becoming independently owned while Corin becomes a guest rather than landlord.
## Material Not Owned By Mainline
The mainline does not own Sabine and Corin's private turns, scores, physical progression, declarations,
sealed-envelope events, threshold sequence, or ending. Those belong to the side novel and may enter a
mainline book only through a separately reviewed shared-continuity bridge.

View file

@ -0,0 +1,72 @@
# Fortune Below Mainline And Sabine/Corin Source Boundary
## Status
This is an operating source-ownership boundary. It controls retrieval and planning scope; it does not
promote provisional events into canon.
## Mainline Books Two And Three
The mainline owns events that change the expedition, household, legal, financial, or publicly observable
state of the ensemble. This includes:
- Selka's folded-route investigation and professional authority.
- Cerys's allocation, release, employment, belonging, and mainline relationship choices.
- The estate, Hallow Street, route-office, equipment, and expedition arcs.
- Sabine's rescue, legal release, independent alternatives, chosen return, paid work, and relationships with
the household when those events are selected into an approved mainline book state.
- Only the minimum externally observable Sabine/Corin continuity needed by another mainline scene.
The approved two-chapter Book Two pilot contains no Sabine/Corin side-novel event. Chapter 1 excludes the
game entirely, and Chapter 2 concerns Cerys's freedom question.
## Sabine/Corin Side Novel
The side novel owns the private progression of Sabine and Corin's relationship across the time occupied by
Books Two and Three. This includes:
- The game, turns, scores, rules, private experiments, and physical thresholds.
- Private declarations, the sealed envelope, its handling, and its resolution.
- The ten-turn manuscript, the preferred threshold sequence, alternate follow-ups, and rejected experiments.
- Scene-level causes and consequences that are not independently visible to the mainline ensemble.
Mainline planning must not retrieve these materials. A side-novel event does not become a mainline fact merely
because its chronology overlaps a mainline book.
## Shared Continuity
Shared continuity must be deliberately copied into a reviewed bridge document. It is limited to public or
externally observable resulting states, never private scene choreography.
Current provisional Book Two bridge candidates are:
- Sabine and Corin begin a private game after Sabine is free and has chosen to remain at the estate.
- Mainline scenes may show brief, unexplained evidence that the game continues.
- The game remains unresolved at the end of the current provisional Book Two structure.
Potential Book Three bridge facts remain unapproved until the side-novel chronology and manuscript assembly
are approved. Mainline work must not assume declarations, envelope resolution, physical completion, standing
permission, or expected repetition.
## Retrieval Rules
- Mainline work may retrieve mainline sources and reviewed shared-continuity bridges.
- Side-novel work may retrieve side-novel sources and reviewed shared-continuity bridges.
- Neither work may retrieve the other work's planning, scenes, or manuscript drafts directly.
- Series reference is reserved for genuinely shared world and established continuity material.
- A document labelled provisional or planning cannot be registered with canon authority.
- Mixed documents must be superseded and replaced by separately scoped documents rather than shared broadly.
## Current Ownership Decisions
- `book-two-folded-route-restructure-plan.md` belongs to mainline Book Two planning.
- `book-two-folded-route-pilot-2-chapters.json` and its chapter briefs belong to mainline Book Two.
- `sabine-corin-side-book-30-chapter-plan.md` belongs to side-novel planning.
- Sabine/Corin scene menus, turn plans, private-scene drafts, the ten-turn manuscript, threshold prose, and
optional follow-ups belong to the side novel.
- The complete `No Final Move` experiment belongs to the side work as a noncanon experiment, not as a
provisional mainline manuscript.
- `fortune-below-book-two-expanded-friendship-and-hallow-arcs.md` is mixed legacy planning and must be
superseded rather than used as a governing source by either work.
- `fortune-below-series-arc.md` and `fortune-below-restructure-canon.md` identify themselves as planning and
must not retain canon authority.

View file

@ -0,0 +1,43 @@
# Sabine And Corin Side-Novel Ledger
## Authority
This is a provisional scope and continuity ledger for the side novel. It does not canonize its candidate
assembly or determine exactly which events fall during Book Two versus Book Three.
## Mainline Prerequisites
The side novel may rely on these events only after their corresponding mainline book states are approved:
- Sabine is removed from an imminent coercive sale.
- Her legal claim is cancelled at the first practical opportunity.
- She receives real alternatives and can leave without losing safety or subsistence.
- She investigates those alternatives and returns by choice.
- She undertakes paid estate work with independent authority.
The side novel must not rewrite the folded-route investigation, Cerys's freedom, expedition outcomes, estate
ownership, or other mainline state transitions.
## Side-Novel Ownership
The side novel exclusively owns:
- Shopping With Corin, Full Attention, Two Cups, and the expensive dance lessons.
- The six-turn sequence and its individual chapter candidates.
- The First Journey, Envelope After, Long Game, and He Likes Her Happy.
- The ten-turn manuscript and all private relationship developments inside it.
- The preferred threshold run from Reserved Accidental Threshold Escalation through Fortunately.
- Optional follow-ups, rejected experiments, and candidate menus as noncanon alternatives.
## Current Preferred Ending State
The preferred ending material currently proposes that Sabine and Corin openly love one another, continue the
game, and have deliberately completed one previously deferred physical threshold. It does not create standing
permission, expected repetition, or an end to the game. These remain side-novel outcomes until the manuscript
and chronology are approved.
## Mainline Export Rule
No private side-novel event is exported automatically. A reviewed bridge may expose only the minimum public
result another book requires, such as the household noticing that the relationship has changed. Scores,
private choreography, envelope contents, and scene-level causality remain private to the side novel.

View file

@ -20,7 +20,7 @@ from control_plane.authoring.models import (
) )
from control_plane.authoring.standalone_scenes import StandaloneSceneService from control_plane.authoring.standalone_scenes import StandaloneSceneService
from control_plane.projects.models import Project from control_plane.projects.models import Project
from model_router.router import ModelResponseContract from model_router.router import ModelCapability, ModelResponseContract
pytestmark = pytest.mark.django_db(transaction=True) pytestmark = pytest.mark.django_db(transaction=True)
@ -38,6 +38,42 @@ class FakeReviewRouter:
) )
class OutOfScopePlanningRouter:
def complete(self, request):
assert str(request.purpose) == str(ModelCapability.STORY_PLANNING)
return ModelResponseContract(
model="test-planner",
content=json.dumps(
{
"purpose": "Perform the approved turn.",
"pov_character": "Protagonist",
"tense": "past",
"location": "Records room",
"time_context": "Chapter 1",
"present": ["Protagonist"],
"beats": [
{"text": "The chapter performs turn 1.", "required": True},
{
"text": "The planner adds a Floor ninety-six inspection.",
"required": True,
},
],
"exact_values": [
{"label": "approved turn", "value": "turn 1"},
{"label": "background floor", "value": "Floor ninety-six"},
],
"constraints": [],
"forbidden_events": [],
"ending_state": "Turn 1 is complete.",
"final_image": "The protagonist closes the record.",
"boundary_constraints": [],
"continuity_questions": [],
}
),
metadata={},
)
@pytest.fixture @pytest.fixture
def work(tmp_path: Path) -> Work: def work(tmp_path: Path) -> Work:
series = Series.objects.create(title="Test Series", slug="test-series") series = Series.objects.create(title="Test Series", slug="test-series")
@ -338,6 +374,42 @@ def test_run_sync_uses_only_scenes_bound_to_exact_state_and_chapter(work: Work)
assert revision.revision == 2 assert revision.revision == 2
def test_book_scene_plan_discards_exact_values_outside_approved_scope(work: Work) -> None:
state_service = BookStateService(FakeReviewRouter())
state = state_service.create(work=work, content=book_content(1))
make_review_ready(state)
state = state_service.approve(state)
state_service.start_run(state)
scene_service = StandaloneSceneService(OutOfScopePlanningRouter())
scene = scene_service.create(
work=work,
title="Chapter 1",
brief="Perform turn 1 and stop.",
book_state=state,
book_chapter_key="chapter-1",
)
scene = scene_service.plan(scene)
assert scene.plan["exact_values"] == [
{"label": "approved turn", "value": "turn 1"}
]
assert scene.plan["beats"] == [
{"text": "The chapter performs turn 1.", "required": True}
]
assert scene.plan["purpose"] == "Advance turn 1."
assert scene.plan["ending_state"] == "Turn 1 is complete."
assert scene.generation_metadata["planning"][
"discarded_out_of_scope_exact_values"
] == [{"label": "background floor", "value": "Floor ninety-six"}]
assert scene.generation_metadata["planning"]["discarded_model_proposed_beats"] == [
{
"text": "The planner adds a Floor ninety-six inspection.",
"required": True,
}
]
def test_lifecycle_persists_audit_metadata_and_run_policy(work: Work) -> None: def test_lifecycle_persists_audit_metadata_and_run_policy(work: Work) -> None:
service = BookStateService(FakeReviewRouter()) service = BookStateService(FakeReviewRouter())
state = service.create( state = service.create(