SPEC.md
Rama main · réplica de lectura del repositorio original.
# NovelWorld Service Specification
Status: H0 candidate `private-preview-v1`
Purpose: Define a platform that transforms a supported novel into an interactive world where readers engage
with AI-driven character agents, influence narrative branches, and maintain persistent memory across
sessions.
This document is the intended normative target, not evidence that the current
runtime conforms to every clause. The current supported envelope, known gaps,
and claim dispositions are recorded in
[`docs/PRODUCT_CONTRACT.md`](./docs/PRODUCT_CONTRACT.md). Runtime code,
migrations, and tests remain the source of truth for current behavior. The
clause-by-clause review state is recorded in
[`docs/SPEC_CONFORMANCE.md`](./docs/SPEC_CONFORMANCE.md).
## Normative Language
The key words `MUST`, `MUST NOT`, `REQUIRED`, `SHOULD`, `SHOULD NOT`, `RECOMMENDED`, `MAY`, and
`OPTIONAL` in this document are to be interpreted as described in RFC 2119.
`Implementation-defined` means the behavior is part of the implementation contract, but this
specification does not prescribe one universal policy. Implementations MUST document the selected
behavior.
`LLM` refers to a Large Language Model reached through a configured provider
adapter. A working adapter does not by itself qualify a provider or model.
---
## 1. Problem Statement
NovelWorld is a platform that ingests supported novel text, extracts its characters and world
structure via LLM analysis, and exposes each character as a stateful AI agent that readers can
converse with. The platform solves five problems:
- It turns static novel text into a living, interactive world without requiring author involvement.
- It isolates each reader's experience so that one reader's choices and conversations do not affect
another reader's world state.
- It maintains per-reader, per-character memory across sessions using a four-layer memory pyramid,
so character relationships evolve naturally over time.
- It surfaces key narrative branch points and lets readers make choices that diverge the story,
with the LLM generating canon-consistent consequences.
- It can generate bounded, optional character portraits from textual appearance descriptions;
missing portraits do not remove a character or block import readiness.
Important boundary:
- NovelWorld is a reader-facing interactive platform, not an authoring tool.
- The platform does not modify the source novel text; it only generates derivative interactive
content layered on top of it.
- A reader's choices affect only their own world state, not the canonical novel text.
---
## 2. Goals and Non-Goals
### 2.1 Goals
- Accept novel text via file upload (TXT, EPUB, PDF) or direct paste and parse it into chapters and
characters without manual annotation.
- Generate a character agent for every extracted character, with personality, background, and
speaking style derived from the source text.
- Maintain a four-layer memory pyramid per reader per character: short-term, mid-term, long-term,
and permanent layers with automatic compression and semantic retrieval.
- Stream character dialogue responses via Server-Sent Events (SSE) so readers see text appear
progressively.
- Present branch choice nodes at key chapters and persist the reader's selections in a world state
document.
- Use an original `PlayerEntity` as the primary identity; retain character identity only as a
compatibility path for conversation and branch choices, with the agency boundary in §8.2.
- Generate character avatar images from appearance descriptions using an image generation API.
- Enforce per-reader memory isolation so no reader can observe another reader's conversation
history or world state.
- Provide a user account system with JWT-based authentication and bcrypt password hashing.
### 2.2 Non-Goals
- Modifying or annotating the source novel text.
- Multi-reader shared world state or collaborative sessions.
- Real-time multiplayer interaction between readers.
- Authoring tools for creating original novels.
- Prescribing a specific LLM provider. Operators may configure compatible
endpoints, but only separately qualified provider/model slices are supported.
- Built-in content moderation in the private-preview profile. A public profile
remains blocked on the H2 policy and enforcement controls.
---
## 3. System Overview
### 3.1 Main Components
1. `API Gateway`
- Receives all inbound HTTP requests.
- Validates JWT tokens and injects authenticated user context.
- Routes requests to downstream microservices.
- Proxies SSE streams without buffering.
2. `User Service`
- Manages user registration, login, and token issuance.
- Stores bcrypt-hashed passwords.
- Issues and validates JWT access tokens.
3. `Novel Service`
- Accepts novel uploads and text pastes.
- Orchestrates LLM-based parsing: chapter splitting, character extraction, world summary
generation.
- Triggers bounded avatar generation for eligible extracted characters.
- Stores parsed artefacts in PostgreSQL and, when enabled, original upload bytes in object
storage.
4. `Agent Service`
- Manages character agent sessions.
- Builds LLM prompts from character profile, world state, and memory layers.
- Streams LLM responses back to the caller via SSE.
- Projects committed turns into bounded recent context and periodic mid-term summaries.
- Retrieves any available long-term and permanent records without implying that current writers
populate them.
5. `Narrative Service`
- Identifies key branch nodes in chapters.
- Presents choice options to readers.
- Persists reader choices in the world state document.
- Generates LLM-derived consequence text for each choice.
6. `Database` (PostgreSQL 18)
- Single source of truth for all structured data.
- Uses `pgvector` extension for semantic memory retrieval.
7. `Object Storage` (optional, S3-compatible)
- Retains original uploaded bytes only when the operator enables source retention.
- Uses server-generated storage keys. Provider-hosted avatar bytes remain outside NovelWorld;
only returned URL metadata is stored.
8. `Cache` (Redis)
- Stores a bounded, reconstructable recent-message projection.
### 3.2 Abstraction Layers
NovelWorld is easiest to implement when organized into these layers:
1. `Identity Layer` (user auth)
- JWT issuance, validation, and refresh.
- Password hashing and verification.
2. `Ingestion Layer` (novel parsing pipeline)
- File parsing, chapter splitting, character extraction, world summary, avatar generation.
3. `Agent Layer` (character AI)
- Memory pyramid management, prompt construction, LLM invocation, SSE streaming.
4. `Narrative Layer` (branch logic)
- Node detection, choice presentation, world state mutation, consequence generation.
5. `Storage Layer` (persistence)
- PostgreSQL query helpers, Redis cache helpers, S3 upload/download helpers.
6. `Gateway Layer` (routing and auth)
- JWT middleware, request routing, SSE proxy, CORS handling.
### 3.3 External Dependencies
- A configured LLM provider adapter.
- Optionally, configured image-generation and embedding adapters.
- PostgreSQL 18 with the `pgvector` and `uuid-ossp` extensions installed.
- Redis 7 or later.
- Optionally, an S3-compatible object storage endpoint for retained source bytes.
---
## 4. Core Domain Model
### 4.1 Entities
#### 4.1.1 User
Fields:
- `id` (UUID v4)
- Stable primary key.
- `email` (string, max 320 chars)
- Unique. Used as login identifier.
- `password_hash` (string)
- bcrypt hash, cost factor MUST be at least 12.
- `name` (string or null)
- `avatar_url` (string or null)
- `role` (enum: `user` | `admin`)
- Default: `user`.
- `email_verified` (boolean)
- Default: `false`.
- `created_at` (timestamptz)
- `updated_at` (timestamptz)
- `last_sign_in` (timestamptz or null)
#### 4.1.2 Novel
Fields:
- `id` (UUID v4)
- `user_id` (UUID, foreign key → User)
- The reader who imported this novel.
- `title` (string, max 500 chars)
- `author` (string or null)
- `cover_url` (string or null)
- `description` (string or null)
- `world_summary` (string or null)
- LLM-generated summary of the novel's world, factions, and rules.
- `genre` (string or null)
- `total_chapters` (integer)
- Set after parsing completes.
- `status` (enum: `pending` | `parsing` | `ready` | `error`)
- `pending`: uploaded but not yet parsed.
- `parsing`: LLM pipeline is running.
- `ready`: accepted chapters and the current extraction results are committed.
- `error`: pipeline failed; `parse_error` contains the reason.
- `parse_error` (string or null)
- `deviation_mode` (enum: `canon` | `creative` | `remix`)
- Controls how strictly the agent adheres to source text. Default: `canon`.
- `original_file_key` (string or null)
- S3 key of the uploaded source file.
- `created_at` (timestamptz)
- `updated_at` (timestamptz)
#### 4.1.3 Chapter
Fields:
- `id` (UUID v4)
- `user_id` (UUID, nullable foreign key → User)
- `null` identifies a canonical pre-divergence node.
- Non-null nodes belong exclusively to one player's generated timeline.
- `novel_id` (UUID, foreign key → Novel)
- `chapter_number` (integer)
- 1-indexed. Unique within a novel.
- `title` (string or null)
- `content` (text)
- Full chapter text as extracted from the source.
- `summary` (string or null)
- LLM-generated one-paragraph summary.
- `is_key_node` (boolean)
- `true` if the chapter contains a narrative branch point.
- `key_node_description` (string or null)
- Human-readable description of the branch point, used in the choice UI.
- `word_count` (integer)
- `created_at` (timestamptz)
#### 4.1.4 Character
Fields:
- `id` (UUID v4)
- `novel_id` (UUID, foreign key → Novel)
- `name` (string, max 200 chars)
- `aliases` (array of strings)
- Alternative names used in the text.
- `role` (enum: `protagonist` | `antagonist` | `supporting` | `minor`)
- `description` (string or null)
- Narrative description as extracted from the text.
- `personality` (string or null)
- Structured personality summary used in agent system prompts.
- `background` (string or null)
- Character backstory.
- `speaking_style` (string or null)
- Description of how the character speaks, used in agent system prompts.
- `appearance` (string or null)
- Physical appearance description, used as the avatar generation prompt.
- `avatar_url` (string or null)
- URL of the generated avatar image.
- `avatar_status` (enum: `pending` | `generating` | `ready` | `error`)
- `first_appearance_chapter` (integer or null)
- `traits` (JSONB)
- Extensible key-value store for additional character attributes.
- `created_at` (timestamptz)
- `updated_at` (timestamptz)
#### 4.1.5 CharacterMemory
One memory record in the four-layer pyramid. Memories are scoped to a `(character_id, user_id)`
pair so that each reader maintains a private relationship with each character.
Fields:
- `id` (UUID)
- Version 4 by default. A permanent fact projected from a committed world
turn uses a deterministic UUID v5 derived from that turn's UUID v4 under a
private fixed namespace; it MUST NOT reuse the client-controlled turn UUID
directly.
- `character_id` (UUID, foreign key → Character)
- `user_id` (UUID, foreign key → User)
- `layer` (enum: `short` | `mid` | `long` | `permanent`)
- `content` (text)
- Memory data. A new committed-world-turn permanent fact is bounded JSON
containing only facts with explicit provenance for its target character:
the reader action only when its kind is character-directed (`converse`,
`ally`, or `oppose`) and its target is that character UUID, events only
when their actor list names that character, and relationship mutations
only for that character. A UUID collision in a location or thread target
MUST NOT count as character witness. Other turn mutations and generated
narrative prose MUST NOT be promoted into that character's memory.
A consumer MUST NOT classify content as an authoritative committed-world
fact from JSON fields alone. It MUST also verify the deterministic UUID v5
against the embedded source-turn UUID and the local layer, importance,
character/user/novel, witness, and chapter-scope metadata; an unverified
row that claims the committed-world protocol MUST be rejected at ingress
before persistence and omitted from authoritative retrieval. The internal
write endpoint MUST return success only after this complete gate passes and
the canonical fact is inserted or an exact durable replay is confirmed;
malformed counts/shape, witness or scope drift, a non-deterministic ID,
unknown fields, and importance other than 7 return `422` with no write.
- `importance` (integer, 1–10)
- Higher values survive compression longer.
- `embedding` (vector(1536) or null)
- Semantic embedding for similarity search. REQUIRED for `long`; optional for
`permanent`, whose durable fact remains directly retrievable without it.
- `access_count` (integer)
- Incremented each time this memory is retrieved during prompt construction.
- `last_accessed` (timestamptz or null)
- `expires_at` (timestamptz or null)
- Set only for `short` layer entries. Null means no expiry.
- `created_at` (timestamptz)
#### 4.1.6 ChatTurn
The durable idempotency and fencing record for one logical conversation turn.
Fields:
- `id` (client-generated UUID v4 from `Idempotency-Key`)
- `user_id`, `character_id`, `novel_id` (UUID ownership scope)
- `request_fingerprint` (32-byte message fingerprint; plaintext is not stored while pending)
- `chapter_context`, `reader_identity`, `reader_identity_type`,
`reader_character_id`, `deviation_mode` (immutable server-side prompt snapshot)
- `world_revision` (nullable 32-byte Narrative `WorldState` fingerprint)
- Every new claim stores the exact revision returned with its causal context
before provider work. Historical completed/failed rows may remain null and
are retained for export/deletion, but they are not qualified for online
replay. A reclaimed failed/expired claim refreshes this field to the new
snapshot before another provider attempt.
- `status` (`in_progress` | `completed` | `failed`)
- `attempt` (positive fencing token)
- `lease_expires_at`, `failure_code`, `created_at`, `updated_at`, `completed_at`
The state fields MUST agree with the status: only `in_progress` has a lease,
only `failed` has a failure code, and only `completed` has `completed_at`.
At most one `in_progress` turn may exist for a
`(user_id, character_id, novel_id)` conversation, so a following turn builds
its prompt only after the preceding pair is committed. An expired abandoned
turn may be marked `superseded` when a new key claims that conversation.
#### 4.1.7 ChatMessage
One message in a conversation between a reader and a character agent.
Fields:
- `id` (UUID v4)
- `character_id` (UUID, foreign key → Character)
- `user_id` (UUID, foreign key → User)
- `novel_id` (UUID, foreign key → Novel)
- `turn_id` (UUID or null, foreign key → ChatTurn)
- Null only for messages written before the idempotent-turn migration or by a
rolled-back compatible service version.
- `role` (enum: `user` | `character`)
- `content` (text)
- `reader_identity` (string or null)
- `chapter_context` (integer or null)
- The reader's current chapter at the time of the message.
- `created_at` (timestamptz)
Constraint: unique on `(turn_id, role)` where `turn_id IS NOT NULL`.
#### 4.1.8 NarrativeNode
A branch point within a chapter.
Fields:
- `id` (UUID v4)
- `novel_id` (UUID, foreign key → Novel)
- `chapter_number` (integer)
- `description` (text)
- Situation description shown to the reader before the choices.
- `choices` (JSONB array of `NarrativeChoice`)
- Each `NarrativeChoice` has:
- `index` (integer, 0-based)
- `text` (string) — the choice label shown to the reader
- `consequence_hint` (string or null) — brief hint about the consequence
- `created_at` (timestamptz)
#### 4.1.9 PlayerChapter
A persisted prose projection of one chapter in a player's forked timeline.
The source `Chapter` remains immutable; this projection is rebuilt from
committed player state and is never shared between users.
Fields:
- `id` (UUID v4)
- `user_id` (UUID, foreign key → User)
- `novel_id` (UUID, foreign key → Novel)
- `chapter_number` (integer)
- `content` (text) — the complete effective chapter shown to this player
- `origin` (`choice` | `continuation`)
- `created_at`, `updated_at` (timestamptz)
Constraint: unique on `(user_id, novel_id, chapter_number)`.
#### 4.1.10 UserChoice
A reader's selection at a NarrativeNode.
Fields:
- `id` (UUID v4)
- `user_id` (UUID, foreign key → User)
- `novel_id` (UUID, foreign key → Novel)
- `node_id` (UUID, foreign key → NarrativeNode)
- `chapter_number` (integer)
- `choice_index` (integer)
- `choice_text` (string)
- Snapshot of the choice label at the time of selection.
- `consequence` (string or null)
- LLM-generated consequence narrative.
- `created_at` (timestamptz)
#### 4.1.11 WorldState
Aggregated state of a reader's journey through a novel.
Fields:
- `id` (UUID v4)
- `user_id` (UUID, foreign key → User)
- `novel_id` (UUID, foreign key → Novel)
- `state` (JSONB)
- Structure:
```json
{
"choices": [{ "chapter": 3, "choice_index": 1, "choice_text": "..." }],
"relationships": { "<character_id>": { "affinity": 7, "trust": 5 } },
"world_events": ["event description 1", "event description 2"]
}
```
- `updated_at` (timestamptz)
Constraint: unique on `(user_id, novel_id)`.
#### 4.1.11 ReadingProgress
A reader's current position in a novel.
Fields:
- `id` (UUID v4)
- `user_id` (UUID, foreign key → User)
- `novel_id` (UUID, foreign key → Novel)
- `current_chapter` (integer)
- Default: 1.
- This is initial progress metadata, not proof that chapter 1 exists or is
readable. Import acceptance may create the default row before chapters are
available; consumers MUST validate chapter availability independently.
- `reader_identity` (string or null)
- The name the reader uses when entering the world.
- `reader_identity_type` (enum: `self` | `character`)
- `self`: reader enters as themselves.
- `character`: reader adopts a character's identity.
- `reader_character_id` (UUID or null, foreign key → Character)
- Set only when `reader_identity_type = character`.
- `deviation_mode` (enum: `canon` | `creative` | `remix`)
- `last_read_at` (timestamptz)
- `created_at` (timestamptz)
Constraint: unique on `(user_id, novel_id)`.
### 4.2 Normalization Rules
- All UUIDs MUST be version 4 unless otherwise specified. The deterministic
permanent-memory projection ID in §4.1.5 is the only current version-5
exception.
- All timestamps MUST be stored as `TIMESTAMPTZ` (UTC-normalized).
- `chapter_number` is 1-indexed throughout the system.
- Character `name` comparisons for deduplication MUST be case-insensitive.
- `deviation_mode` defaults to `canon` at all levels unless explicitly overridden by the reader.
---
## 5. Novel Ingestion Pipeline
### 5.1 File Acceptance
The Novel Service MUST accept:
- Plain text files (`.txt`) in UTF-8, BOM-marked UTF-16, or GBK up to 10 MiB.
- EPUB files (`.epub`) up to 20 MiB.
- PDF files (`.pdf`) up to 20 MiB.
- Direct text paste payloads up to 5 MiB (UTF-8 encoded JSON string body).
On receipt, the service MUST:
1. Validate the declared type, size, and extractable content before accepting the import.
2. If source retention is enabled, store uploaded bytes under a server-generated managed key with
durable cleanup intent. With retention disabled, extraction is request-local.
3. With retention disabled, split and validate deterministic chapters before acceptance. With
retention enabled, the claimed import job rebuilds deterministic chapters from the retained
object before any provider-backed enrichment.
4. Atomically commit the `pending` Novel and a `novel_import_jobs` row — with chapters/chunks when
splitting preceded acceptance, otherwise at the `source` stage — and cancel the retained
object's cleanup intent in the same transaction. Attempt an immediate claim with a renewable
lease; startup recovery claims any job left pending. Return `202` and the Novel ID only after
the durable boundary commits.
An accepted import MUST be recoverable after process death from its committed `source`,
`chapters`, or `enriched` stage. A job committed at `source` MUST replay the retained object to
rebuild chapters; a missing or unreadable retained object fails the job with actionable re-upload
guidance and never advances the stage.
A default progress row may be committed before chapters exist; its initial chapter coordinate does
not establish chapter availability or novel readiness.
### 5.2 Parsing Pipeline
Provider-backed enrichment runs asynchronously after file acceptance. It MUST:
0. When the committed stage is `source`, read the retained object for the Novel's managed key,
re-extract and re-split deterministic chapters within the acceptance bounds, and atomically
commit the chapters and the `chapters` stage advance behind the `(novel_id, attempt)` fence
before any provider call.
1. Set `status = parsing`.
2. Claim exactly one current attempt; renew its lease during external work and fence every
authoritative write by `(novel_id, attempt)`.
3. Atomically replace the character and relationship snapshot produced by the Character Extractor
(see §5.4).
4. Identify key narrative nodes using the Node Detector (see §5.6); retries MUST NOT duplicate
authoritative node state.
5. Persist the world summary and chapter count, advancing the durable stage to `enriched`.
6. Commit a source-validated canonical model before advancing the job and Novel to `completed` and
`ready` in one transaction.
7. Start bounded avatar generation for eligible characters (see §5.7) as a non-authoritative
projection that cannot block readiness.
On an unrecoverable current-attempt error, the pipeline MUST atomically set the job to `failed` and
the Novel to `error`, store an actionable public message in `parse_error`, and keep detailed
provider/internal errors in logs rather than exposing them to readers. Pending or expired
in-progress jobs MUST be reclaimed after restart; completed jobs MUST NOT call a provider again.
Provider calls across attempts MUST stay inside the approved import-provider budget policy
([`import-provider-budget-v1`](docs/LLM_PROVIDERS.md#import-provider-budget)): a job at the attempt ceiling MUST be marked terminally `failed` with
`budget_exhausted` and actionable re-upload guidance, and MUST NOT be reclaimed or retried.
### 5.3 Chapter Splitter
Input: full novel text (string).
Algorithm:
1. Attempt pattern-based splitting using the following heuristics in order:
- Lines matching `^(第[零一二三四五六七八九十百千万\d]+[章节回]|Chapter\s+\d+|CHAPTER\s+\d+)` are
chapter boundaries.
- Lines matching `^\s*\d+\s*$` (standalone numbers) are chapter boundaries.
- Paragraphs separated by two or more blank lines, where the paragraph is fewer than 100 chars,
are chapter boundaries.
2. If fewer than 2 chapter boundaries are detected, fall back to LLM-based splitting:
- Send the first 8000 tokens of the text to the LLM with a structured prompt requesting a JSON
array of `{ chapter_number, title, start_offset, end_offset }` objects.
3. Each chapter MUST have a non-empty `content` field after trimming.
4. Chapters MUST be numbered sequentially starting from 1.
### 5.4 Character Extractor
Input: full novel text or per-chapter summaries (implementation-defined).
The extractor MUST invoke the LLM with a structured output schema requesting:
```json
{
"characters": [
{
"name": "string",
"aliases": ["string"],
"role": "protagonist|antagonist|supporting|minor",
"description": "string",
"personality": "string",
"background": "string",
"speaking_style": "string",
"appearance": "string",
"first_appearance_chapter": "integer or null"
}
],
"relationships": [
{
"from_character": "string",
"to_character": "string",
"relationship_type": "string",
"description": "string",
"strength": "integer 0-100"
}
],
"world_summary": "string",
"genre": "string"
}
```
The extractor MUST:
- Deduplicate characters by name (case-insensitive) and merge aliases.
- Extract at least all characters who appear in more than one chapter.
- Return at most 50 characters per novel to bound LLM cost.
### 5.5 World Summarizer
Input: the novel title and the bounded representative sample used by character
extraction (§5.4). The current implementation returns the world summary in the
same structured extraction response rather than making a second provider call.
The extraction request MUST request a world summary covering:
- Setting (time period, geography, society).
- Major factions or groups.
- Core conflict.
- Unique world rules (magic systems, technology, etc.) if applicable.
The summary MUST be stored in `novels.world_summary` and MUST NOT exceed 2000 characters.
### 5.6 Node Detector
Input: bounded chapter summaries with their real chapter numbers.
The Node Detector MUST return bounded candidate branch points. It MAY batch or
sample chapter summaries; the product contract does not require one provider
call per chapter. Model output MUST use a structured schema:
```json
{
"nodes": [
{
"chapter_number": "integer",
"description": "string",
"choices": [
{ "text": "string", "hint": "string" }
]
}
]
}
```
For each accepted node, the service MUST:
- Set `chapters.is_key_node = true`.
- Store the `description` in `chapters.key_node_description`.
- Let the Narrative Service validate and persist the user-visible node and
choices when they are requested.
The number of choices per node MUST be between 2 and 3 inclusive. Current
reader-facing node text is Simplified Chinese; other languages remain outside
the supported journey.
### 5.7 Avatar Generation
For each eligible extracted character with a non-null `appearance` field, up to
the documented cost cap of 30 characters per import:
1. Set `characters.avatar_status = generating`.
2. Construct a bounded image-generation prompt from the `appearance` field.
3. Invoke the configured image adapter; rendering parameters are adapter-owned.
4. Store the provider-returned URL as non-authoritative metadata and set
`characters.avatar_status = ready`.
5. On failure, set `characters.avatar_status = error`. Avatar failure MUST NOT block the novel
from reaching `status = ready`.
Characters beyond the cap remain available without generated avatars. The
provider owns image-byte retention and deletion unless a future reviewed change
adds a NovelWorld-owned media lifecycle.
### 5.8 Extraction Quality Gates
Each supported positive extraction slice MUST meet the versioned
`extraction-quality` policy (`docs/EXTRACTION_QUALITY.md`) before it can claim
qualification:
1. Every positive case MUST produce a non-empty, structurally valid accepted
canon; malformed and unsupported inputs MUST reach their expected bounded,
actionable errors and are labeled separately.
2. Accepted canon facts MUST meet the approved per-category coverage,
precision/hallucination, chronology, and provenance thresholds on the
versioned corpus.
3. Rejecting every input or producing an empty accepted canon MUST NOT pass
any gate.
4. Recorded evaluation MUST be deterministic and required in CI; live
evaluation MUST record provider, model, corpus/rubric versions, and the
exact commit, and MUST fail closed on malformed judge output.
---
## 6. Character Agent System
### 6.1 Agent Identity
Each character agent derives its identity from the Character entity. The agent system prompt MUST
include:
- The character's `name` and `aliases`.
- The character's `role` in the story.
- The character's `personality`, `background`, and `speaking_style`.
- The novel's `world_summary`.
- The reader's current `chapter_number` from `ReadingProgress`.
- The reader's `reader_identity` and `reader_identity_type`.
- The reader's `deviation_mode`.
The system prompt MUST instruct the character to:
- Respond only in the character's established voice and speaking style.
- Not reveal plot events that occur after the reader's current chapter (anti-spoiler constraint).
- Acknowledge the reader's identity (self or character) appropriately.
- Incorporate relevant memories naturally without breaking character.
### 6.2 Memory Pyramid
The memory pyramid has four layers. Each layer has distinct characteristics:
| Layer | Storage | Max Entries | Retrieval | Expiry |
|---|---|---|---|---|
| `short` | PostgreSQL messages + bounded Redis projection | 50 projected messages | Recency | Account/novel lifecycle |
| `mid` | PostgreSQL | Implementation-defined | Recency + Importance | Account/novel lifecycle |
| `long` | PostgreSQL + pgvector | Implementation-defined | Semantic similarity | Account/novel lifecycle |
| `permanent` | PostgreSQL + optional pgvector projection | Implementation-defined | Bounded direct candidates (journey recency plus legacy importance/recency) + optional semantic similarity | No automatic eviction; account/novel lifecycle still applies |
#### 6.2.1 Short-Term Layer
- Contains raw conversation turns from the current and recent sessions.
- Persisted to PostgreSQL for durability; Redis holds only a bounded projection and has no
time-based expiry.
- In the prospective self-mode path, each ten newly completed self turns enrolls one immutable
summary window containing the exact twenty role-paired messages for that interval. The window
records its fixed Mid result identity, source turn/message interval, reader/novel/character
scope, persona and progress provenance, and claim/lease fences. Historical or rollback-period
chat is not backfilled; character-mode turns remain unenrolled.
- A bounded worker may recover only known-unsent windows. A dispatched failure, interruption,
expiry, or uncertain provider acknowledgement is terminal and never authorizes another logical
model call. Successful publication inserts the fixed Mid result once under the current fence;
stale owners cannot publish. Re-upgrade resumes only eligible known-unsent windows.
#### 6.2.2 Mid-Term Layer
- Contains compressed summaries of past conversation sessions.
- Created by the Compression Pipeline from short-term entries.
- Retrieved by recency and importance score during prompt construction.
- Each created mid-term summary attempts best-effort promotion to a 1536-dim
embedded long-term entry. An unavailable or incompatible embedding leaves the
mid-term summary intact.
#### 6.2.3 Long-Term Layer
- Contains semantically indexed memories of significant events and relationship milestones.
- Each entry MUST have an `embedding` vector.
- Retrieved via cosine similarity search using `pgvector`.
#### 6.2.4 Permanent Layer
- Contains immutable facts: the reader's name, major choices, and critical relationship events.
- Entries in this layer MUST NOT be compressed or removed by memory maintenance.
- Novel or account deletion MUST erase them under the data-lifecycle contract.
- The committed fact MUST be stored before embedding generation. Embedding is
an optional search projection; provider failure or an incompatible vector
MUST NOT discard the fact.
- Retrieved directly within the reader's chapter boundary and, when a valid
embedding exists, optionally via cosine similarity. Directly included IDs
MUST NOT be duplicated by semantic retrieval.
- A world-turn fact's chapter coordinate MUST be the committed session's
effective source context (`source_context` when present, otherwise
`entry_context`).unlocked_through_chapter, not merely its player checkpoint,
because any validated mutation may have been influenced by canon up to that
source high-water mark. A later reading-progress rewind MUST therefore
exclude that fact until the boundary is unlocked again.
- At first adoption, upgrade migration MUST mark every pre-contract completed
world turn terminal `skipped`; old output has no trustworthy character-
witness provenance and MUST NOT be promoted into a new structured fact.
Migration MUST retain pre-contract memory rows for export and ordinary
lifecycle deletion rather than guess an unavailable historical protagonist.
Every Agent prompt-consumer path MUST quarantine the old producer class
(`permanent`, importance 7, UUID version nibble 4) from direct and semantic
retrieval. The quarantine deliberately does not require an RFC UUID variant,
because the former producer accepted any UUID with version nibble 4. This
conservative boundary can hide a legitimate legacy row in the same narrow
class, but MUST NOT delete it or fabricate a replacement historical fact.
- An authenticated committed-world-turn fact MUST enter direct or semantic
prompt memory only when the persisted conversation-turn identity snapshot is
`self`. Until memory records carry durable reader-identity provenance, a
`character` turn MUST omit every direct `mid`/`long`/`permanent` and semantic
memory candidate and MUST NOT create a summary or other derived memory
projection. This deliberately sacrifices unprovable continuity rather than
leak self-mode or another-character context.
- A committed-world-turn fact MUST be scoped to the deterministic protagonist
only when that protagonist is explicitly named by a direct UUID action
target, an event's `actor_character_ids`, or a relationship mutation. No
protagonist, or no such witness evidence, produces a terminal `skipped`
projection rather than inferred character knowledge. The stored payload MUST
then be filtered to that same evidence: a directly targeted action's
observable kind and target UUID, events
naming the protagonist as actor, and that protagonist's relationship
changes only. The reader's free-form `intent` is private motivation, not
witnessed dialogue, and MUST NOT cross into character memory. Witnessing one part of a turn MUST NOT expose unrelated event,
knowledge, inventory, location, thread, faction, or canon-event mutations.
### 6.3 Compression Pipeline
The prospective mid-term projection enrolls each ten newly completed self turns as one immutable
summary window of twenty role-paired messages. Enrollment and the fixed Mid result identity are
recorded in the fenced chat-completion transaction; historical and rollback-period chat is not
backfilled, and character-mode turns remain unenrolled.
Rollback to an older Agent suspends this guarantee; see ADR 0005 for rollback, deadlines,
transport retries, and evidence limits.
Steps:
1. A bounded worker claims a due window with its source turn/message interval, exact
`(character_id, user_id, novel_id)` scope, persona/progress provenance, and a monotonic
claim/lease fence. Only known-unsent work is recoverable; once dispatch is recorded, any
failure, interruption, expiry, or uncertain acknowledgement is terminal and cannot trigger
another logical model call.
2. Revalidate the exact twenty committed role-paired `ChatMessage` sources and all scope,
provenance, and progress fences before provider work and before publication.
3. Build the existing bounded summary input from that frozen source window (at most 24,000
Unicode characters, favoring recent messages), then send it to the LLM with a prompt requesting
a concise summary of the key events, emotional tone, and relationship developments.
4. In one fenced transaction, insert the summary under its fixed Mid result identity and mark the
window saved. A stale owner cannot publish, and a lost acknowledgement leaves the one saved
result or an explicit unresolved terminal outcome.
5. Keep committed chat messages in PostgreSQL; the Redis projection remains bounded independently.
6. Attempt best-effort 1536-dim long-term promotion of the new mid summary.
Separately, every committed open-world turn stores a durable projection
status on its `world_turns` row: `pending` until a protagonist-scoped
permanent fact is acknowledged as `saved`, or eligibility is conclusively
recorded as `skipped`. A same-key replay or autonomous recovery pass MUST
attempt to compensate eligible `pending` rows using the same deterministic memory ID. Narrative MUST
scan a bounded batch periodically and durably rotate attempted rows so a
persistently ineligible or unavailable oldest batch cannot starve later
pending turns. Narrative may record `saved` only after Agent
returns success for a fully authenticated insert or exact durable replay;
parsing a JSON-shaped payload or reserving an unauthenticated row is not an
acknowledgement. `saved` and `skipped` are terminal and MUST
replay the committed result without an Agent Service call. After projection
returns either terminal candidate but before acknowledging it on the turn
row, Narrative MUST recheck both persisted `self` identity and the committed
world's source high-water from one current server-owned progress snapshot.
A concurrent rewind MUST return the content-free
`reading_progress_behind_world` error and leave the row `pending`; restoring
progress allows the same key or a later recovery pass to compensate without
another world generation or commit.
### 6.4 Prompt Construction
Before invoking the LLM for a conversation turn, the Agent Service MUST construct the prompt as
follows:
1. **System prompt**: character identity block (§6.1).
2. **Memory data blocks**: in `self` mode, bounded JSON explicitly labeled as
untrusted data, never as instructions:
- Bounded directly retrieved `permanent` entries within the server-owned
chapter boundary. Authenticated committed-world facts are selected before
legacy prose consumes the permanent-memory character budget; at least the
newest eligible fact MUST remain whole when one fits the block, and the
selected facts are emitted in causal turn order after any legacy text.
- Bounded `long`/`permanent` semantic results when a compatible query
embedding is available, excluding permanent IDs already included directly.
- Bounded recent/important `mid` entries.
A `character` turn MUST omit this entire block until each candidate has
durable identity provenance, and MUST NOT project that turn into the
unscoped memory layers.
3. **Conversation history**: bounded recent committed PostgreSQL `ChatMessage`
records within the chapter boundary, in chronological order. Scope MUST be
applied in PostgreSQL before ordering and limiting: `self` admits completed
self claims plus legacy messages without a persisted identity claim, while
`character` admits only completed claims whose `reader_character_id` exactly
matches that turn's persisted identity snapshot. Failed, incomplete,
other-character, self, and unprovenanced rows MUST be absent from a character
prompt. Redis is only a projection and is not prompt authority.
4. **Source and reader context**: bounded chapter-visible lore, persisted
progress, identity, and deviation mode.
5. **Causal revision and world context**: before claiming a new attempt, Agent
MUST request Narrative's version-4 causal snapshot for the exact
user/novel/character scope. Narrative MUST derive its 32-byte
`WorldState::fingerprint()` and any branch/world context from one PostgreSQL
snapshot. A `character` turn receives and persists only that opaque revision;
it MUST neither receive nor inject Player branch/world context. A `self` turn
may consume the bounded context from the same response. After open-world entry,
Narrative may return a bounded,
server-committed transport DTO. After validating its scope and bounds, Agent
MUST build a separate provider-facing allowlist containing only world
time/turn, that character's numeric relationship score and goals, a canonical
event naming that character, directly targeted recent player actions, and
actor-listed recent player events. Routing UUIDs, protocol/model metadata,
player name/location, branch choices, active threads, relationship-change
prose, perception, and model-generated reasons MUST NOT be sent to the
provider. Those fields have no independent per-character witness provenance
in this version and are therefore omitted rather than
treated as character knowledge. Structured action fields are authoritative
facts; rendered narrative fields are labeled as generated projections and
cannot override structured state or agency. Free-form player `intent` is not
character-visible and MUST be omitted. A recent action supplied
to a character MUST have a character-directed kind (`converse`, `ally`, or
`oppose`) whose target is that character UUID. Events use their separately
filtered fields, while relationship context exposes only its numeric score,
rather than exposing an originating action or private intent. A supplied player-origin event MUST list the character in its own
`actor_character_ids`, and the Agent consumer MUST reject an event that does
not name the requested character. The world view MUST carry the committed
session's `entry_context.unlocked_through_chapter` as its canonical source
high-water mark. If that mark is absent during a rolling deploy or exceeds
current server-owned reading progress, the Agent MUST omit the entire world
view before provider invocation, including after reading progress is
rewound. Agent requests for a causally fenced view MUST declare
`X-World-Context-Version: 4`; an old Narrative peer returns no view and the
new Agent fails before claim/provider work. Versions 2 and 3 retain their
existing world-only and branch/world response shapes for old consumers; no
field is added to those strict envelopes. Narrative and Agent MUST each enforce the character-directed
action kind and target UUID before provider invocation. The current
structural visibility model does not infer line of sight, location
propagation, or social knowledge beyond explicit IDs.
6. **User message**: the current reader input.
Each data block has a fixed character/item bound and the complete prompt MUST
remain within the Agent Service prompt budget. A request that still exceeds the
total budget fails closed before provider invocation.
### 6.5 Streaming Response
The Agent Service MUST stream the LLM response to the caller via SSE. Every new
turn request MUST include `Idempotency-Key: <UUID v4>`. The key identifies one
logical turn and MUST be reused for transport retries; reusing it with a
different user, character, novel, or message returns `409 idempotency_conflict`.
SSE event format:
```
event: delta
data: {"content": "<token>"}
event: done
data: {"turn_id":"<UUID>","committed":true,"replayed":false}
```
On error:
```
event: error
data: {"code":"<error_code>","message":"<human-readable message>","turn_id":"<UUID>"}
```
`done` is a commit acknowledgement, not merely an upstream EOF marker. The
Agent Service MUST NOT emit it until a single PostgreSQL transaction has:
1. Fenced the active `chat_turns.attempt`.
2. Stored exactly one `user` and one `character` `ChatMessage` for the turn.
3. Marked the turn `completed`.
Client disconnect MUST stop delivery only; the in-process producer continues to
the durable boundary. A provider error, malformed frame, or EOF without the
provider's explicit terminal event MUST fail the attempt and MUST NOT be
followed by `done`. After provider completion and before the PostgreSQL message
transaction, Agent MUST re-read the same Narrative V4 scope. If its revision is
unavailable, malformed, or differs from the claim, the attempt fails closed
with zero committed messages and no `done`; a later same-key reclaim records the
new revision before retrying. The final re-read and Agent commit are separate
service transactions, so this rule fences commits that occur during provider
generation without claiming cross-service linearizability. A completed key
with a qualified revision is replayed without another LLM call. An active key
returns `409 turn_in_progress` with `Retry-After`; a failed or expired attempt
may be reclaimed with a higher fencing attempt and a refreshed revision.
Redis and memory-compression updates are derived, best-effort projections after
the PostgreSQL commit. Prompt history MUST be read from committed PostgreSQL
messages so Redis lag or restart cannot erase the immediately preceding turn.
---
## 7. Narrative Branch System
### 7.1 Node Presentation
When a reader advances to a chapter where `is_key_node = true`, the Narrative Service MUST:
1. Check whether the reader has already made a choice at this node by querying `UserChoice` for
`(user_id, node_id)`.
2. If a choice exists, return the existing choice and consequence without re-presenting options.
3. If no choice exists, generate/cache the `NarrativeNode` under
`(user_id, novel_id, chapter_number)` and return its `description` and
`choices` array. Runtime nodes MUST be player-scoped because their prompt
includes PlayerEntity, private WorldState, and deviation mode; an
uncommitted legacy shared node MUST NOT start a new choice.
4. Node persistence is first-writer-wins. A competing generation MUST reload
and return the durable node without replacing its id, description, anchor,
choices, or creation time.
5. Before returning uncommitted options, reload the latest WorldState and omit
them if open-world entry started, the Player checkpoint sealed the chapter,
or the committed choice prefix reached the chapter. A committed same-user
legacy choice remains exactly replayable.
### 7.2 Choice Submission
When a reader submits a choice:
1. Validate that `choice_index` is within the bounds of `NarrativeNode.choices`.
2. Treat `(user_id, node_id)` as the natural operation key. If that key already
contains the same `choice_index`, replay the committed choice, world state,
and effective chapter without another provider call. If it contains a
different index, return `409 choice_conflict` and reload the committed
choice, world state, and effective chapter; the
submitted index MUST NOT overwrite or masquerade as the committed choice.
3. Snapshot a deterministic fingerprint of the `WorldState` used for
generation, then invoke the LLM to generate replacement prose from the
exact inline anchor through the end of the current chapter (see §7.3).
4. Under the same `WorldState` row lock used for persistence, require a new
choice's fingerprint to still match and its chapter to be strictly later
than the latest committed choice chapter. A stale, same-chapter, or backward
draft MUST return `409` without leaving a `UserChoice`, state mutation, or
`PlayerChapter`. Exact same-index replay bypasses these new-choice guards.
5. Atomically persist the `UserChoice`, updated `WorldState`, and the complete
current `PlayerChapter`. If an earlier continuation projection occupies the
same chapter, the committed choice rewrite MUST replace it atomically. An
existing choice projection may only replay when it matches the committed
result; any other projection conflict MUST roll back the whole transaction.
The source `Chapter` MUST remain unchanged.
6. Optionally update `WorldState.state.world_events` if the consequence implies a world-level event.
7. Return both the consequence and the persisted effective chapter to the reader.
### 7.3 Consequence Generation
Input: novel world summary, chapter content, choice text, and the reader's prior choices from
`WorldState`.
The LLM MUST be prompted to generate a consequence narrative that:
- Is consistent with the novel's world and tone.
- Acknowledges the reader's prior choices where relevant.
- Is between 100 and 400 words.
- Ends with a clear transition to the next chapter.
The consequence MUST be stored in `UserChoice.consequence`, and the resulting
complete chapter MUST be stored as a `PlayerChapter` with `origin = choice`.
For an already committed choice whose legacy same-chapter projection is absent
or has `origin = continuation`, exact replay MUST reconstruct the chapter from
the immutable source, inline node anchor, and committed consequence, then
atomically restore the `choice` projection without another provider call.
### 7.4 Full Chapter Regeneration After Divergence
The first committed choice establishes a causal boundary. From the next chapter
onward, the original chapter text MUST NOT be displayed as the player's current
story. For each requested chapter, the Narrative Service MUST:
1. Return an already persisted `PlayerChapter` when one exists.
2. Require the immediately preceding player chapter, preventing gaps in the
generated timeline.
3. Generate the entire chapter from the previous player chapter, committed
`WorldState`, novel world summary, and the original chapter as reference-only
source material.
4. Remove or rewrite canonical events whose preconditions no longer hold; it
MUST NOT silently reset causality to the source novel.
5. Persist the winning result idempotently under
`(user_id, novel_id, chapter_number)` before displaying it.
6. Fail closed when generation is unavailable rather than fall back to original
prose that contradicts the player's timeline.
All runtime-generated narrative nodes MUST be player-scoped. Two users MUST NOT
receive or mutate the same generated node even before divergence because Player
identity, private world state, and deviation mode already affect the prompt.
### 7.5 World State Consistency
The Narrative Service MUST ensure that:
- `WorldState` is created on first access for a `(user_id, novel_id)` pair if it does not exist.
- All mutations to `WorldState.state` are atomic (use database transactions or optimistic locking).
- The `relationships` map in `WorldState.state` uses `character_id` (UUID string) as keys.
- Provider-generated branch transitions are committed only against the exact
deterministic state fingerprint from which they were generated, and new
choices advance strictly by chapter under the same row lock.
- `user_choices` is the durable branch-decision authority. Every element of
`WorldState.state.choices` MUST be a node-keyed projection of exactly one
durable choice, and every durable choice MUST have exactly one projection
with matching node, chapter, index, text, consequence, canon-model version,
and canonical checkpoint. Player creation, open-world entry, choice commit
or replay, and world-turn reservation or completion MUST validate this
bijection under their transaction lock. Missing, duplicate, unkeyed,
malformed, or mismatched projections MUST return a typed conflict before a
provider call or new authoritative write; exact replay MUST NOT bypass it.
### 7.6 Canonical Mainline and Open-World Evolution
A completed source novel MAY be transformed into a living world, but generated
prose MUST NOT become the authoritative state. The Narrative Service MUST keep
two distinct layers:
1. `CanonStoryModel`: an immutable, versioned, source-backed graph of story
arcs, ordered events, locations, factions, world rules, character states,
unresolved threads, and the canonical ending. Extracted entities and events
MUST retain chapter provenance.
2. `PlayerTimeline`: an append-only overlay beginning at a canonical checkpoint
and containing only the player's committed actions and validated world
transitions.
Generated `PlayerChapter` prose is a durable read projection of committed
timeline state, not the authoritative transition log. It may be regenerated by
future migration tooling, while the source `Chapter` and canonical graph remain
immutable.
The user MUST enter as a durable `PlayerEntity`: a new person who does not exist
in source canon and has a chosen identity, background, capabilities, optional initial location,
inventory, relationships, faction standing, and discovered knowledge. The
primary interaction MUST describe actions taken by this player. It MUST NOT ask
the player to choose actions on behalf of canonical characters.
The initial location MAY be unspecified. A supplied location MUST be a Canon
place whose source evidence is visible at the chosen unlocked checkpoint; the
selector MUST NOT reveal later-chapter place names. The absence of extracted
places MUST NOT block player creation: store an unspecified location as `null`, not as a
fabricated Canon place. Open-world entry and character context MUST preserve
that absence; a later valid travel transition MAY set a visible place.
An open-world session is reconstructed from a canonical checkpoint plus its
player entity and player timeline. Canonical events continue when their
preconditions remain true, and canonical characters act according to their own
goals and knowledge. Player actions MAY observe, assist, obstruct, delay, or
redirect those events. A world turn MUST produce a structured transition containing
event, relationship, location, and thread changes alongside the narrative
rendering. The transition MUST be schema-valid, entity-valid, spoiler-bounded,
idempotent, and atomically committed before its prose is shown as complete.
Each committed action advances only that reader's `world_time` by one step and
records the time when the next eligible canonical event advances. Exact-key
replay MUST return the same committed step without advancing time again.
One world-turn generation selects at most four relevant living characters from
the action target, current event, and rotating source-backed goals. Their
independent actions are recorded as actor-attributed events in that reader's
timeline; an absent generated action MAY be filled from the frozen current
event or goal, without inventing a character location. This is bounded
character agency within one world turn, not a separate provider call per actor.
When an advanced action fails, the player's proposed effects are discarded;
the neutral failed attempt and independently source-backed character actions
may still be recorded as that reader's world time advances.
In advanced mode, initial attribute allocation still obeys the template's
8–15 bounds and point budget. Later turns MAY apply one bounded change per
attribute only when it cites a player-only event in the committed transition;
each delta
is −2 to +2 and the resulting score stays within 1–20. Subsequent server-owned
D20 checks use that committed score. Failed checks discard proposed attribute
changes with the other model-proposed player mutations.
The first successfully committed start MUST seal its `WorldSession.entry_context`.
A later valid start request is a resume hint and MUST return that persisted
winner without replacing it with a freshly derived candidate context.
An existing world admits its next source chapter through
`POST /narrative/{novel_id}/world/source`. This operation MUST preserve the
original entry, Player checkpoint and rules, attributes, existing event/thread
states and committed turns. It MUST NOT call a model, roll dice or increment
world time. The optional active `source_context` MUST retain the original
checkpoint and pinned Canon version; absent fields preserve schema-v1 canonical
serialization, while expanded sessions use schema v2. Every current source
consumer MUST use the effective context; historical memory projections MUST use
the source context of their immutable committed result.
The reader runtime MUST automatically admit the next chapter after its current
committed turn reaches terminal memory projection and no scheduled or delayed
canonical event remains in the effective scene. Empty event extraction also
permits this transition; it MUST NOT trap the world at its entry chapter. The
current turn's observed source chapter (or the immutable entry source for legacy
turns) MUST match the effective source. Each turn therefore permits at most one
admission, including across reloads. A later committed turn can admit the next
chapter. Reaching the book's end stops source extension but not world actions.
This rule is per user and novel; the shared Canon graph MUST remain immutable.
Fresh self identity, Player, progress, route and successful world reads MUST
agree before automatic work. Source orchestration MUST read current progress
before advancing it. Automatic initial work MUST reject an observed rewind,
and every source attempt MUST supply
`expected_current_chapter` to Novel's advance command. Novel MUST atomically
require that exact chapter and self identity before writing; a mismatch returns
`reading_progress_changed` without a progress write or source dispatch.
Pending turns, projections, source operations,
rewinds and unknown outcomes MUST retain their existing recovery fences. The
reader MUST NOT need a separate next-scene click during normal progression.
The strict source command carries `expected_turn_number`,
`expected_source_chapter` and `target_chapter`, where target is exactly the
observed source plus one, with a UUID-v4 `Idempotency-Key`. Novel owns the
monotonic `POST /progress/{novel_id}/advance` and pinned-model source-delta read;
the existing progress PUT retains explicit rewind semantics. Every new whole
catalog item, link and death effect MUST have complete nonempty evidence at or
below the admitted chapter. Future citations MUST NOT be cropped to retain
future text. Existing definitions MUST remain unchanged; new threads MUST NOT
replace modified/resolved threads, and new events MUST NOT precede an already
advanced event in canonical order. Missing pinned source, bounded-catalog
overflow or incompatible ordering MUST fail explicitly.
Narrative MUST commit source authority and its owner-local operation journal
atomically. Every mixed world/turn/source-operation writer MUST lock the world
row before its journal row, including reclaim, completion and supersession;
supersession MUST use that same transaction. In-progress actions and completed
turns awaiting memory projection MUST exclude source admission. Modern world
actions MUST carry the observed effective source chapter and reject a stale
coordinate before generation and at commit. Legacy completed keys MUST retain
their original fingerprint and exact-result replay. Source-operation replay
MUST retain original metadata and return a freshly guarded current view, never
restore a historical world. A postcommit progress rewind MUST return a
content-free unknown outcome and retain the original retry key.
The browser MUST persist a bounded user/novel-scoped source command and key
before either owner's write. It MUST suppress automatic absolute progress
writes while preparing, pending, recovering or resolving a partial outcome.
Successful admission and exact replay MUST refresh persisted progress and
navigate to the maximum of current effective source, operation source and
persisted reading progress. Unknown progress MUST retain the key and block
mutations until refetch; it MUST NOT decrease progress to match a stale route.
A deliberate rewind MUST immediately hide later derived content and chat.
Pending recovery MUST allow explicit original-chapter reading to restore
progress without discarding an unknown source key or admitting another source
operation. Reading an admitted chapter in an extended world MUST NOT dispatch
a new legacy branch continuation; previously committed chapter projections
remain replayable. Source journal rows MUST participate in export and lifecycle erasure. Migration
0036 is a semantic release barrier: old readers MUST NOT restart after expanded
schema-v2 sessions; recovery is forward deployment.
For one reader and novel, committed world turns form a single total order. A
new request MUST carry the `expected_turn_number` of the world view from which
the reader chose its action. Narrative MUST use that client-observed revision
in the durable claim and reject a different logical key when authoritative
state has advanced; it MUST NOT silently reinterpret a delayed action as the
next turn. Exact-key replay matches the persisted expected revision and remains
valid after its own commit. A new turn MUST be generated from current authoritative state plus a bounded,
ordered tail of committed turn actions and narrative endings so it continues
from what the reader was last shown. Thread-advance and thread-resolution
actions MUST target a currently open thread, and the committed transition MUST
update that exact thread. This player timeline uses the PostgreSQL journal;
a separate graph database is not required.
The browser MUST keep the original world-turn `Idempotency-Key` when no
response arrives, a `5xx` is returned, the turn is still in progress, or the
server reports `409 turn_outcome_unknown`. A retry MUST reuse that key until a
committed replay or a terminal rejection establishes the outcome.
Players MAY enter at any checkpoint already unlocked by server-side reading
progress only when every already committed branch choice and chapter-tagged
player event is at or before that checkpoint. Creating the `PlayerEntity`
seals this checkpoint as the upper bound for later branch-choice commits. A new
choice after open-world entry MUST be rejected; a request for the already
committed choice index MUST still replay, while a different index MUST
conflict. These rules MUST be repeated under the same database lock that
persists player or choice state so a race cannot create an invalid prefix.
Every new world action MUST also revalidate the stored entry checkpoint against
the complete committed choice/player-event prefix before provider invocation
or world commit. A legacy or externally restored state whose prefix extends
beyond that checkpoint MUST fail with a conflict; it MUST NOT project a fact at
the lower checkpoint chapter.
Future canon remains spoiler-bounded. Character agents, exploration, scheduled
canon events, and future narrative turns MUST consume the same committed player
timeline subject to the explicit character-visibility rule in §6.4.
Every player-entry/world-state/world-session read, new turn, exact turn replay,
and choice or branch response MUST recheck server-owned reading progress against
the session's canonical source high-water immediately before returning
world-derived state or prose. If progress has been rewound below that boundary,
those endpoints MUST return `409 reading_progress_behind_world`, MUST NOT
include the derived result, and a new world turn MUST NOT reserve a key or invoke
the provider. A choice or node generated concurrently with the rewind may remain
durable but MUST NOT cross the response boundary until progress is restored.
The effective-chapter endpoint is the safe exception: while behind, it MUST
return the immutable source chapter with `generated = false`, MUST NOT generate
or return a player projection, and MUST recheck after any in-flight generation
before responding. The browser MUST synchronously replace cached generated
chapter/world content with source content while behind and refetch after every
progress change. Generated-chapter cache identity MUST include the active
reader identity and server-owned progress boundary. While route and persisted
progress differ, the browser MUST use their lower chapter for character
availability, hide every generated chapter, and close a chat whose character
is not confirmed visible at that boundary. Restoring progress to the boundary
makes exact player results available again.
The `world_turns` row MUST durably record `memory_projection_status` as
`pending`, `saved`, or `skipped`. A fresh committed turn does not return success
until its projection reaches a terminal state; failure after the authoritative
turn commit is an unknown outcome, not a rollback of that turn. Replaying the
same logical-turn key MUST attempt to compensate eligible `pending` rows idempotently. Replaying
`saved`/`skipped` MUST return the exact committed result without recontacting
the Agent Service. Narrative MUST also run bounded autonomous compensation for
committed `pending` rows and durably rotate attempted scan positions so one
unrecoverable batch cannot starve later turns. While a committed turn remains `pending`, the persistence
boundary MUST refuse every different logical-turn key for the same user and
novel; the unresolved turn and an in-progress turn share one database-
serialized authority slot. Before contacting Agent and again before changing
`pending` to a terminal status, Narrative MUST recheck persisted `self` identity
and the committed result's source high-water from one server-owned progress
snapshot. If progress concurrently
falls behind, the request MUST return content-free
`reading_progress_behind_world`, keep the row `pending`, and allow the same key
or a later recovery pass to finish after progress is restored without
regenerating or recommitting the world turn. Dependency failure, identity
change, ambiguous acknowledgement, and invalid persisted data MUST also leave
the row `pending` rather than discard a committed fact.
The successful world-turn POST MUST include its terminal
`memory_projection_status`. A browser that receives that terminal response MAY
clear the pending request immediately; compatibility with an older response
shape MAY confirm the journal row before clearing.
Before sending a world turn, the browser MUST store the bounded, validated
`(action, expected_turn_number, Idempotency-Key)` tuple per `(user, novel)` in
`sessionStorage`. While
the result is ambiguous it MUST prevent a different action, restore the same
tuple across a same-tab reload or remount, and offer only same-key confirmation.
A matching committed journal row remains ambiguous while
`memory_projection_status = pending`; the browser MUST clear the stored request
only after that row reaches `saved`/`skipped`, or after an explicit terminal
rejection of the original POST establishes that no commit occurred. When local
storage is absent but the authoritative bounded journal contains its unique
`pending` row, the browser MUST reconstruct the exact action, turn UUID, and
`turn_number - 1` expected revision, lock the form, and offer same-key
compensation. When the journal is not yet committed, the authoritative view MAY
return the exact `in_progress` action, turn UUID, and expected revision only if
the revision matches the current session and the action remains semantically
valid against its sealed entry context. The view MUST reject repeated world-
state drift rather than mix that claim with another revision. The browser MUST
prefer either server-owned pending form over stale browser storage, lock the
form, and require explicit same-key confirmation; expired-lease supersession
remains unchanged. A failed
confirmation refresh after a successful POST remains ambiguous regardless of
that refresh response's status. `sessionStorage` covers the interval before a
commit becomes journal-visible; committed-pending recovery does not depend on
the original tab. While a matching request remains pending, the current page
MUST poll the authoritative view at a bounded fixed interval and stop after a
terminal state. Same-key user confirmation remains a compatibility fallback
alongside the autonomous server scan.
When first-run setup, login, registration, or session confirmation establishes the authenticated
principal, the browser MUST remove NovelWorld pending world-turn records for
every other principal while retaining exact recovery records for the confirmed
principal. Before exposing that principal, it MUST also cancel and clear the
in-memory server-query cache so a private response from the previous principal
cannot satisfy the new principal's query. An authenticated mutation that
finishes after this boundary MUST NOT insert its response into that cache;
successful mutations invalidate and refetch only queries active under the
current authenticated principal. On logout, successful account deletion,
absence of an authentication token, or a confirmed `401`/`403` session
invalidation, the browser MUST clear the server-query cache and remove every
NovelWorld pending record before exposing the unauthenticated state. A
transient authentication failure or failed account-deletion request MUST retain
the applicable cache and recovery records. Every transition MUST preserve
unrelated `sessionStorage` keys.
Every protected asynchronous browser operation MUST remain bound to the access
token and, when already known, principal that initiated it. A late `401` or
`403` MAY invalidate the global session only when the request's actual bearer
token still equals the current token. A late identity refresh or account
deletion MUST NOT clear, replace, or expose a newer principal's auth state,
query/chat cache, or recovery records. Account-export bytes MUST be discarded
before inspection, Blob creation, or download if either the token or principal
changed while the request was in flight. When another browsing context changes
the shared `auth_token`, the current context MUST synchronously clear private
query and chat state and reload/re-authenticate before issuing or presenting
principal-scoped work; a refresh-token-only event or unchanged token MUST NOT
trigger that boundary.
After a novel is successfully removed from the authenticated user's shelf, the
browser MUST remove only that principal-and-novel pending record. A failed
novel-removal request MUST retain it, and neither outcome may alter pending
records belonging to another principal or novel.
---
### 7.7 Prompt-versioned basic game rules
Narrative play MUST remain the default. Advanced D20 rules MUST be immutable
and identified by the canonical-model version and exact prompt version. A
newly created advanced profile MUST bind the selected template identity; every
existing profile or session MUST continue to resolve its exact previously bound
template identity and MUST NOT be silently upgraded to a newer prompt. Existing
v1 bindings remain on v1; a profile explicitly bound to a later prompt version
remains on that exact version. A missing bound template
is an explicit unavailable result, never permission to select the latest one.
For v2, Novel Service MUST derive only basic mechanics from the bounded,
stably ordered `CanonStoryModel.world_rules` set (at most 64 source rules).
The provider MAY select only from the server-owned twelve-key ability
vocabulary and the eight supported action kinds, plus bounded numeric values
and source chapter references. The server MUST provide all labels and
descriptions from fixed dictionaries and MUST reject provider-authored free
text, unknown keys, duplicate mappings, invalid numbers, or missing actions.
Generated rules MUST NOT contain plot summaries, future events, character
goals, unresolved threads, ending details, or other narrative reveals. Source
chapter references are provenance only for v2; they MUST NOT gate template
availability. A ready novel with positive reading progress MAY request v2
rules. This does not relax any existing progress, ownership, target, canon,
spoiler, or hard-rule check on world actions or narrative context.
The v1 template and its existing chapter-unlock behavior MUST remain available
to profiles and sessions already bound to v1. The total generation-claim budget
is three per novel and canonical-model version across all prompt versions; a
new prompt version MUST NOT refill that budget. Schema evolution MUST preserve
ready templates and existing reader bindings. The v2 source must not be treated
as evidence that canonical extraction captures every world rule or that
generated mappings are semantically correct.
### 7.8 Reader-confirmed series worlds
Series definitions and shelf associations MUST be private to one reader and
owned by Novel Service. Optional matching is bounded, nonspoiler guidance; the
reader MUST confirm every association, and manual selection MUST remain
available. Book titles and authors MAY aid display but MUST NOT be required to
find candidates: reader uploads may use arbitrary names and omit authors.
Provider evidence MAY include existing title/author/genre and at most six
deduplicated location or faction names, each at most 40 characters, only when
all valid source citations refer to chapter 1 and a citation contains that name.
Descriptions, excerpts, world-rule prose, later-chapter clues and plot MUST NOT
be sent to a provider or returned as matching evidence. Novel MAY use bounded
whole-book location/faction names with citations containing the name solely for
private candidate ranking. It MUST abstain on scan overflow or ambiguous local
evidence. A local evidence suggestion after Laya abstains MUST be labeled as a
server heuristic and MUST NOT become a confirmed series without reader action.
Missing authors MUST NOT count as an author match. Confirmed memberships
organize candidates; title similarity is never proof of a shared world.
Creation MUST preserve the selected source novel identity and atomically
associate that source book. It MUST NOT generate rules or claim provider work.
A series MAY be created or associated with no shared setting yet. In that state
its background is pending: it MUST NOT be returned as a setting to Narrative,
shown as shared to readers, or used to make shared D20 rules available. The
reader MAY explicitly confirm a supplied background at creation or later through
a separate confirmation. That transition MUST be owner-scoped and write-once;
empty or invalid backgrounds MUST be
rejected. Creation MAY freeze an exact ready `novel-game-rules-v2` template
from the source book as an immutable safe snapshot, but before background
confirmation that template MUST NOT make shared D20 rules available. If no
ready source template exists, a later explicit source-book generation request
MAY bind the exact ready v2 template once, preserving its real source novel,
model version, and chapter provenance. The pending-to-ready transition MUST be
owner-scoped, idempotent under concurrency, and unable to replace an existing
snapshot. Narrative-only sessions MAY use the setting only after it has been
confirmed, including while D20 rules are still pending.
Novel MAY offer a reader-requested private, read-only background draft composed
from the source book's persisted LLM world summary, source-cited whole-book
Canon rules, locations and factions, and named character relationships. Preview
MUST require the reader's Ready source access, stay
within the 2,000-character background bound, use `no-store`, and dispatch no
provider work. It MUST NOT save or share the draft before the reader explicitly
confirms the background, or expose draft content as matching evidence. If extraction is unavailable, manual entry remains
possible; a suggested series match never confirms the background.
For a pending series, Novel MAY instead compose a bounded reader-requested draft
from every confirmed member's saved extraction. It MUST verify ownership and
Ready access for every included book, report which members supplied evidence,
and fail rather than silently omit a missing or over-limit member. The draft is
editable before confirmation; it is a compact overview, not proof that every
book has identical characters, chronology, or world rules. A target book's
Canon owns its own changes and takes precedence over shared background. Later
plot secrets MUST NOT become opening knowledge solely because they appeared in
the draft; the reader reviews and removes such material before confirmation.
When a series-bound profile or session is resolved, Narrative MUST obtain the
series snapshot through Novel's HTTP contract. Source-book canon and target-book
canon remain separate; source chapter numbers MUST NOT be relabeled as target
chapters. A bound session freezes the confirmed setting and rule identity.
Pure narrative sessions MAY share the setting without enabling D20. Player
attributes, inventory, progress, timeline, and checks remain per-player and
per-target-book. Reassociation MUST NOT rewrite existing profile bindings,
frozen session context, committed checks, or replay results. Removing a shelf
association MUST NOT delete the series definition or frozen snapshots; account
erasure removes the reader-owned records. Canonical source deletion MUST NOT
erase another book's safe snapshot.
Novel MAY offer reader-requested community grouping suggestions from explicitly
opted-in private series memberships. Contribution MUST default to off, cover
current and future members only after reader consent, support withdrawal, and
follow account export/erasure. Names, backgrounds, rules and player state MUST
remain private. Suggestions MUST use exact shared canonical novel identities,
exclude the requester from support, count each distinct contributor account
once per pair, and require at least three accounts. Separate uploads or editions
MUST NOT be merged by title or filename. An opted-in contributor grouping a
pair into different series makes that pair ambiguous, not a validated negative
fact. Every existing recipient-series member MUST have direct qualifying
evidence with the target; missing evidence, competing groups, an already-linked
target or scan overflow MUST abstain. Only the recipient's Ready shelf metadata
MAY be returned, without donor identity or counts. No transitive inference,
provider dispatch, recommendation cache or automatic association is permitted.
Current membership/consent changes MUST affect the next read. These are grouping
hints, not proof of shared world facts, calibrated accuracy or Sybil resistance.
See ADR 0012 for bounded deadlines and additive migration 0035.
Laya matching reuses `LAYA_API_URL` and `LAYA_API_KEY`, with a 300 ms connect
and 2 s total deadline, no HTTP retries, 8 KiB request and 16 KiB response
limits, four admission slots, at most eight candidates, and a 0.8 abstention
threshold that is not calibrated accuracy evidence. It MUST NOT receive scope
UUIDs, full novels, or plot text. Suggestions MUST be cached as reader-owned
PostgreSQL state, bound to the current bounded evidence, candidate set, method
and policy. Laya's identity MUST also bind private ranking evidence; DeepSeek
MUST reuse a completed result while its exact provider input and candidate
scope remain unchanged. Cache hits MUST recheck current shelf access and Ready sources.
An explicit DeepSeek second opinion MAY use the existing user/platform provider
configuration and accounting under the dedicated `series_matching` operation.
It MUST refuse non-DeepSeek providers and Diagnostic bindings before dispatch,
bind the actual resolved provider/model to its cache identity, and make at most
one physical provider attempt within a 30 s total deadline and a 512-token
total output ceiling, including reasoning tokens when thinking is enabled.
Insufficient output or invalid JSON MUST abstain without increasing that ceiling.
HTTP retries, JSON
fallback and application repair dispatches MUST be disabled. A durable unique
claim MUST precede dispatch; unknown, failed or interrupted outcomes MUST NOT
be automatically redispatched. A result-only query MUST NOT create a claim or
dispatch, including when evidence or configuration has changed. No ordinary
recognition request may implicitly
invoke paid fallback. Results contain only a validated candidate and fixed
status/reason codes, never raw model rationale. Matching cache rows follow
shelf removal and account erasure, and their safe results are exportable.
Migration 0031 is additive and follows the
normal managed migration path; it does not add to the existing 0021/0024/0025/
0030 release barriers. Novel, Narrative, and frontend versions must be upgraded
together. Older peers reject the new prompt version; once series-bound state
exists, rollback to an application that cannot read it is unsupported.
## 8. Player Identity System
### 8.1 Identity Types
Users MAY choose one of two identity modes. The primary mode is `self`, whose
existing wire name is retained for compatibility:
- `self`: The user creates an original `PlayerEntity` and enters the world as a
new person. The display name does not have to be the user's real identity.
Agents address the player in second person and canonical characters perceive
the player as another person in their world.
- `character`: A legacy compatibility mode in which the reader adopts a
character identity for conversation. Until narrative nodes carry durable
identity provenance, a character-identity reader MAY replay an already
committed branch result but MUST NOT receive or submit a new branch. They
also MUST NOT create a `PlayerEntity`, enter the open world, submit world
turns, read or mutate the world journal, or hold relationship/faction/location
mutation authority. The open world and every new narrative decision remain
exclusive to `self` mode with a durable `PlayerEntity`.
### 8.2 Identity Constraints
- If `reader_identity_type = character`, `reader_character_id` MUST reference a Character that
belongs to the same novel.
- A reader MUST NOT adopt the identity of the character they are currently conversing with.
- Identity changes take effect immediately for new conversation turns; they do not retroactively
alter existing `ChatMessage` records.
- Character-identity readers MUST be refused every Player/open-world endpoint: reading or creating
the Player entry, reading or starting the open-world session, and submitting or replaying a world
turn. This remains true when a previously created `PlayerEntity` is retained; the refusal MUST be
a conflict error, never a silent no-op.
- Character-identity readers MUST NOT receive an uncommitted narrative node or
submit a new narrative choice. The server MUST fail closed before provider or
persistence work; only exact read/replay of an already committed choice is
allowed until node persistence is keyed by durable identity provenance.
- A character-identity chat turn MUST be fenced by the identity snapshot stored
with its idempotent claim: Agent MUST persist and compare the opaque Narrative
causal revision, but MUST neither receive nor inject Player branch/world
context even if the reader concurrently switches to `self` before generation.
- The same persisted claim is the authority for conversation visibility. A
character-identity prompt and history read MUST include only messages backed
by completed claims for the exact same `reader_character_id`, with the scope
predicate applied before pagination. Self, another character, failed or
incomplete turns, and legacy messages without an identity claim MUST be
omitted. Self-mode prompt/history reads MAY retain legacy unclaimed messages
for compatibility.
- Until memory records gain equally durable identity provenance, character mode
MUST return no mid/long/permanent/semantic memory, MUST expose no such records
through the memory read endpoint, and MUST NOT create summary or promotion
projections. Exact same-character committed recent chat is its only current
cross-turn memory source.
- A general WorldState read in character mode MUST expose only the reader's `choices` plus the
required empty `world_events` field. This choices-only projection MUST happen before source
high-water validation, so hidden Player/open-world state neither leaks nor blocks a valid branch
recovery. Internal V4 reads in character mode MUST return only the opaque
causal revision; both branch and world context fields MUST be empty.
- Switching identity from `character` to `self` (or back) MUST NOT create or destroy a
`PlayerEntity`; the `self`-mode open world only exists once the reader explicitly creates one.
- Character-identity data (identity type, name, character id) is portable account data: it MUST
be included in account export and MUST NOT enter the canonical story model.
- In `self` mode, narrative choices MUST be actions performed by the
`PlayerEntity`; they MUST NOT transfer control of a canonical character to the
player.
### 8.3 Deviation Modes
| Mode | Agent Behavior |
|---|---|
| `canon` | Strictly follows the source text. Agent refuses to speculate beyond established facts. |
| `creative` | Allows the agent to extrapolate plausibly within the world's rules. |
| `remix` | Agent may introduce new plot elements while maintaining character consistency. |
---
## 9. Authentication and Authorization
### 9.1 Registration
Input: `email`, `password`, `name` (optional).
The User Service MUST:
0. Reject registration with `setup_required` until the initial administrator exists.
1. Validate that `email` is a valid RFC 5321 address.
2. Validate that `password` is at least 8 characters.
3. Check that no existing user has the same `email` (case-insensitive).
4. Hash the password with bcrypt at cost factor 12 or higher.
5. Create the User record.
6. Return a JWT access token and refresh token.
### 9.2 Login
Input: `email`, `password`.
The User Service MUST:
1. Look up the user by `email` (case-insensitive).
2. Verify the password against `password_hash` using bcrypt.
3. Update `last_sign_in`.
4. Return a JWT access token (expiry: `auth.access_token_expiry_seconds`, default: 3600) and a
refresh token (expiry: `auth.refresh_token_expiry_seconds`, default: 604800).
### 9.3 JWT Structure
Access token claims:
- `sub` (string): user UUID.
- `role` (string): user role.
- `iat` (integer): issued-at Unix timestamp.
- `exp` (integer): expiry Unix timestamp.
The JWT MUST be signed with HMAC-SHA256 using the `JWT_SECRET` environment variable.
A refresh token is single-use: a successful refresh atomically consumes it and
returns both a new access token and a replacement refresh token.
### 9.4 Authorization Rules
- All application endpoints under `/api` except setup status/init, the
deprecated setup LLM probe,
`POST /api/auth/register`, `POST /api/auth/login`,
`POST /api/auth/refresh`, and `POST /api/auth/logout` MUST present a valid
JWT. Logout is authenticated by the opaque refresh token in its request body,
so a client can revoke the session after its access JWT expires. Setup init succeeds only while
the `users` table is empty and atomically creates one administrator, its
refresh token, and (when not supplied by the environment) an encrypted model
configuration. Anonymous setup only accepts provider presets with fixed
HTTPS endpoints; it never accepts an arbitrary URL. Process probes and the
internal metrics routes follow §14 instead of application authentication.
- A user MAY only access novels, characters, memories, and world states that belong to their own
`user_id`.
- The Gateway MUST reject requests with expired or invalid JWTs with HTTP 401.
---
## 10. API Contract
All application endpoints below are prefixed with `/api/`. The Gateway routes
them to the appropriate downstream service; process probes and internal service
routes are outside this public contract.
### 10.1 Authentication Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| GET | `/api/setup/status` | User | None | Administrator and model readiness (`contract: 3`) |
| POST | `/api/setup/init` | User | None | Validate AI settings and atomically create the initial configuration |
| POST | `/api/auth/register` | User | None | Register new user |
| POST | `/api/auth/login` | User | None | Login, returns tokens |
| POST | `/api/auth/refresh` | User | Refresh token | Atomically rotate and issue new access and refresh tokens |
| GET | `/api/auth/me` | User | JWT | Current user profile |
| DELETE | `/api/auth/me` | User | JWT | Permanently delete the acting account and owned application data |
| POST | `/api/auth/logout` | User | Refresh token | Invalidate the submitted refresh token without requiring a valid access JWT |
| GET | `/api/settings/llm` | User | JWT | Read the acting scope without returning a secret: platform for administrators; personal when configured for other users, otherwise platform fallback metadata |
| PUT | `/api/settings/llm` | User | JWT | Validate and update the platform configuration for administrators or the acting user's encrypted personal configuration |
| GET | `/api/settings/llm/usage` | User | JWT | Read key-scoped usage: platform for administrators, personal for other users; return 403 when no personal key is configured |
| GET | `/api/account/export` | Gateway | JWT | Stream the acting user's complete `account-export-v1` NDJSON data |
Protected LLM Settings MUST use fixed official HTTPS presets. Region and API/plan
variants MUST be distinct provider identities, even when their endpoint is shared.
A stored Key MAY be reused only for the same canonical preset and fixed endpoint;
switching variants requires a newly submitted Key. New provider model IDs are
bounded to 200 bytes without whitespace/control characters. Plan compatibility
does not imply provider approval or qualification; limitations and endpoint mappings
are recorded in [`LLM_PROVIDERS.md`](docs/LLM_PROVIDERS.md).
### 10.2 Novel Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| GET | `/api/novels` | Novel | JWT | List user's novels |
| POST | `/api/novels` | Novel | JWT | Import novel (text paste) |
| POST | `/api/novels/upload` | Novel | JWT | Upload one bounded TXT, EPUB, or PDF file |
| GET | `/api/novels/:id` | Novel | JWT | Novel detail |
| GET | `/api/novels/:id/status` | Novel | JWT | Parse status (poll) |
| POST | `/api/novels/:id/retry` | Novel | JWT | Retry an owned failed import from its last committed durable stage; re-upload is required when no chapters remain |
| POST | `/api/novels/:id/lore/search` | Novel | JWT | Search owned, progress-bounded source lore |
| GET | `/api/novels/:id/relationships` | Novel | JWT | Legacy whole-book relationships at exact full progress |
| GET | `/api/novels/:id/relationships/source-v1` | Novel | JWT | Cited Canon v1 relationships at saved reading progress |
| DELETE | `/api/novels/:id` | Novel | JWT | Delete novel |
The `source-v1` read MUST use the acting reader's shelf and saved progress for a
`Ready` novel. It MUST select immutable Canon model version 1. Each returned
relationship MUST retain its complete description and all source citations.
Every citation MUST identify an unlocked chapter and an exact excerpt in that
novel. A relationship with any later citation MUST be withheld as a whole.
Endpoint names MUST have lexical evidence at their declared first appearance.
The read MAY use Novel's existing idempotent chapter-1 initialization when a
reading-progress row is absent. It MUST NOT advance progress or save a graph.
The response MUST contain `novel_id`, `model_version`, `checkpoint_chapter`,
`characters` (`id`, `name`), and `relationships` (`id`, `from_character_id`,
`to_character_id`, `kind`, `description`, `source_citations`). Citations contain
only `chapter_number` and `excerpt`. The response MUST NOT expose confidence,
legacy strength, whole-book persona, ending data, or private player state.
It MUST contain at most 256 characters and 256 relationships, with at most
eight citations per relationship. Text MUST retain the Canon validation limits
without truncation. Missing or invalid authority MUST fail closed.
Novel MUST recheck shelf ownership, Ready status, total chapters, and unchanged
saved progress after all source reads. The read MUST have a five-second deadline
and no automatic retry or provider call. All responses MUST use
`Cache-Control: private, no-store`. Browser query keys MUST include principal,
novel, and progress. The browser MUST withhold the view while progress is
unknown, loading, refetching, or in error.
These facts describe extracted source history. They do not describe later
relationship changes or the reader's private world. Partial relationship access
MUST NOT enable partial-progress character chat. The legacy relationship shape
and exact-full chat boundary remain unchanged. See
[ADR 0014](docs/adr/0014-source-cited-relationships.md).
### 10.3 Chapter Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| GET | `/api/novels/:id/chapters` | Novel | JWT | Chapter list (id, number, title, is_key_node) |
| GET | `/api/novels/:id/chapters/:num` | Novel | JWT | Full chapter content |
### 10.4 Character Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| GET | `/api/novels/:id/characters` | Novel | JWT | Character list |
| GET | `/api/characters/:id` | Novel | JWT | Character detail |
### 10.5 Agent Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| POST | `/api/chat/:characterId/stream` | Agent | JWT | Stream conversation turn (SSE) |
| POST | `/api/chat/:characterId` | Agent | JWT | Complete one non-streaming conversation turn |
| GET | `/api/chat/:characterId/history` | Agent | JWT | Conversation history |
| GET | `/api/memories/:characterId` | Agent | JWT | Memory layer summary |
| DELETE | `/api/memories/:characterId/short` | Agent | JWT | Clear the reconstructable short-term projection |
### 10.6 Narrative Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| GET | `/api/narrative/:novelId/:chapter` | Narrative | JWT | Get branch node for chapter |
| GET | `/api/narrative/:novelId/chapters/:chapter` | Narrative | JWT | Get or generate the player's effective full chapter |
| GET | `/api/narrative/:novelId/player-entry` | Narrative | JWT | Read checkpoint options and the current original player |
| PUT | `/api/narrative/:novelId/player-entry` | Narrative | JWT | Create the original player at an unlocked checkpoint |
| POST | `/api/narrative/choose` | Narrative | JWT | Submit choice |
| GET | `/api/narrative/:novelId/world-state` | Narrative | JWT | Reader's world state |
| GET | `/api/narrative/:novelId/world` | Narrative | JWT | Read the current open-world session |
| POST | `/api/narrative/:novelId/world` | Narrative | JWT | Start the open-world session |
| POST | `/api/narrative/:novelId/world/turns` | Narrative | JWT | Commit an idempotent player action |
| POST | `/api/narrative/:novelId/world/action-suggestion` | Narrative | JWT | Optional non-authoritative action-type hint for an owned, source-visible open world |
### 10.7 Progress Endpoints
| Method | Path | Service | Auth | Description |
|---|---|---|---|---|
| GET | `/api/progress/:novelId` | Novel | JWT | Reading progress |
| PUT | `/api/progress/:novelId` | Novel | JWT | Update chapter position |
| PUT | `/api/progress/:novelId/identity` | Novel | JWT | Set reader identity |
### 10.8 Account Export
`GET /api/account/export` MUST derive the subject from the Gateway-validated
JWT, compose internal-token-authenticated fragments in `user`, `novel`, `agent`,
and `narrative` order, and stream with bounded server memory. The response MUST
use `application/x-ndjson`, `Cache-Control: no-store`, and no `Content-Length`.
It MUST begin with an `account-export-v1` manifest and emit `complete` only after
all four service fragments finish. Clients MUST reject a file without that
terminal record. Queries MUST use explicit field allowlists and deterministic
ordering; credentials, tokens, runtime keys, chat-turn operational state,
embeddings, Redis/search projections, and external provider/operator data MUST
be excluded. Each fragment is a service-local statement snapshot, not a
distributed point-in-time backup. The Gateway MUST bound concurrency and total
elapsed work.
### 10.9 Error Response Format
All error responses MUST use the following JSON structure:
```json
{
"error": {
"code": "<machine-readable error code>",
"message": "<human-readable description>"
}
}
```
Standard error codes:
| Code | HTTP Status | Meaning |
|---|---|---|
| `unauthorized` | 401 | Missing or invalid JWT |
| `forbidden` | 403 | Valid JWT but insufficient permission |
| `not_found` | 404 | Resource does not exist |
| `conflict` | 409 | Unique constraint violation |
| `setup_required` | 409 | Initial administrator must be created first |
| `turn_in_progress` | 409 | The same idempotent turn is still being produced |
| `turn_outcome_unknown` | 409 | The world-turn lease was lost; retry the logical turn with the same key |
| `idempotency_conflict` | 409 | The key was reused for a different request |
| `choice_conflict` | 409 | A different choice is already committed for this reader and node |
| `validation_error` | 422 | Request body failed validation |
| `client_upgrade_required` | 426 | A stale chat client omitted the idempotency contract |
| `rate_limited` | 429 | Request rate exceeded |
| `payload_too_large` | 413 | Request or upload exceeded its byte limit |
| `unsupported_media_type` | 415 | Upload type is not accepted |
| `parse_error` | 422 | Novel parsing pipeline failed |
| `llm_error` | 502 | Upstream LLM API returned an error |
| `storage_error` | 502 | Object storage operation failed |
| `bad_gateway` | 502 | Upstream response was invalid |
| `service_unavailable` | 503 | A required dependency is unavailable |
| `capacity_unavailable` | 503 | Bounded local work capacity is busy; retry later |
| `internal_error` | 500 | Unexpected server error |
---
## 11. Configuration
### 11.1 Environment Variables
All services read configuration from environment variables. No configuration file format is
mandated; implementations MAY use `.env` files for local development.
Runtime variables (some are required only when their integration is enabled):
| Variable | Service | Description |
|---|---|---|
| `DATABASE_URL` | All | PostgreSQL connection string |
| `REDIS_URL` | User, Agent | Redis connection string |
| `JWT_SECRET` | User, Gateway | HMAC-SHA256 signing key, min 32 chars |
| `LLM_API_URL` | Novel, Agent, Narrative | LLM API base URL |
| `LLM_API_KEY` | User, Novel, Agent, Narrative | Optional environment override for LLM authentication |
| `LLM_MODEL` | User, Novel, Agent, Narrative | Model identifier for the environment override |
| `RUNTIME_CONFIG_KEY` | User | 32-byte hex key for encrypting web-provided credentials |
| `INTERNAL_SERVICE_TOKEN` | Gateway, User, Novel, Agent, Narrative | Authenticates internal runtime configuration and account-export reads |
| `IMAGE_GEN_API_URL` | Novel | Image generation API base URL |
| `IMAGE_GEN_API_KEY` | Novel | Image generation API key |
| `EMBEDDING_PROVIDER` | Agent | Embedding provider identity; required with URL and model |
| `EMBEDDING_API_URL` | Agent | OpenAI-compatible local or external embedding API base URL |
| `EMBEDDING_API_KEY` | Agent | Optional embedding API key; omitted for isolated no-auth local endpoints |
| `EMBEDDING_MODEL` | Agent | Embedding model identifier; external responses must contain 1536 finite values; the exact local model's 1024 values are zero-padded to 1536 |
| `S3_ENDPOINT` | Novel | S3-compatible endpoint URL |
| `S3_BUCKET` | Novel | Bucket name |
| `S3_ACCESS_KEY` | Novel | Access key ID |
| `S3_SECRET_KEY` | Novel | Secret access key |
| `SERVICE_PORT` | All | Port the service listens on |
The server Compose file provides an opt-in `local-embedding` profile. It pins
Text Embeddings Inference and `Qwen/Qwen3-Embedding-0.6B` to immutable
image/model revisions, exposes no host port, and uses the same OpenAI-compatible
Agent adapter as an external endpoint. Ordinary deployments may select either
form; only this exact local provider/model pair is zero-padded from 1,024 to
1,536 dimensions, and generation configuration remains separate. Switching
embedding models requires re-embedding stored Long memories.
### 11.2 Tunable Parameters
The private-preview-v1 profile supports these environment settings:
| Parameter | Env Variable | Default | Description |
|---|---|---|---|
| Access token expiry | `AUTH_ACCESS_TOKEN_EXPIRY` | `3600` | Seconds |
| Refresh token expiry | `AUTH_REFRESH_TOKEN_EXPIRY` | `604800` | Seconds |
Other preview limits are fixed source policies, not environment settings:
- A durable summary window contains ten completed turns and twenty paired messages (§6.2.1). Changing this window requires a versioned lifecycle change.
- The optional Redis projection retains at most fifty messages. Prompt assembly selects at most ten recent messages, five Mid entries, and five semantic results. These bounds do not limit the number of stored Mid-memory rows.
- Input caps remain 10 MiB for TXT, 20 MiB for EPUB/PDF, and 5 MiB for pasted text. Character extraction retains at most fifty characters (§5.4).
The preview selects fixed limits to preserve durable window identities, bounded prompts, and the declared input and provider-cost envelope. Raising these limits requires source review and affected checks. Setting the proposed variables below does not change preview behavior.
#### Future configuration recommendations
Future profiles SHOULD provide the following controls when operator needs justify them. These names and proposed defaults remain design guidance. They are not implemented settings. Any implementation must preserve the versioned summary protocol and validate its supported input and cost bounds.
| Parameter | Proposed Env Variable | Proposed Default | Description |
|---|---|---|---|
| Short-term memory limit | `MEMORY_SHORT_TERM_LIMIT` | `20` | Max entries before compression |
| Compression threshold | `MEMORY_COMPRESS_THRESHOLD` | `15` | Trigger compression at this count |
| Mid-term memory limit | `MEMORY_MID_TERM_LIMIT` | `50` | Max mid-term entries |
| Context window turns | `AGENT_CONTEXT_WINDOW_TURNS` | `20` | Chat history turns in prompt |
| Long-term K | `MEMORY_LONG_TERM_K` | `5` | Top-K semantic results |
| Max file size (TXT) | `UPLOAD_MAX_TXT_BYTES` | `10485760` | 10 MB |
| Max file size (PDF) | `UPLOAD_MAX_PDF_BYTES` | `20971520` | 20 MB |
| Max paste size | `UPLOAD_MAX_PASTE_BYTES` | `5242880` | 5 MB |
| Max characters per novel | `PARSE_MAX_CHARACTERS` | `50` | Character extraction cap |
---
## 12. Database Schema Requirements
### 12.1 Extensions
The PostgreSQL database MUST have the following extensions installed:
- `uuid-ossp` — for `uuid_generate_v4()`.
- `pg_trgm` — for trigram-based fuzzy search on `novels.title` and `characters.name`.
- `vector` (pgvector) — for semantic similarity search on `character_memories.embedding`.
### 12.2 Index Requirements
Implementations MUST create the following indexes:
- `users(email)` — unique B-tree.
- `novels(user_id)` — B-tree.
- `novels(status)` — B-tree.
- `novels(title)` — GIN trigram.
- `chapters(novel_id)` — B-tree.
- `chapters(novel_id, is_key_node)` — partial B-tree where `is_key_node = true`.
- `characters(novel_id)` — B-tree.
- `characters(name)` — GIN trigram.
- `character_memories(character_id, user_id)` — B-tree.
- `character_memories(character_id, user_id, layer)` — B-tree.
- `character_memories(embedding)` — HNSW with `vector_cosine_ops`, `m=16`, `ef_construction=64`.
- `chat_messages(character_id, user_id, created_at DESC)` — B-tree.
- `chat_messages(turn_id, role)` where `turn_id IS NOT NULL` — unique B-tree.
- `chat_turns(user_id, character_id, novel_id)` where `status = in_progress` — unique B-tree.
- `world_states(user_id, novel_id)` — unique B-tree.
- `reading_progress(user_id, novel_id)` — unique B-tree.
### 12.3 Migrations
Implementations MUST apply schema changes through versioned migration files. Migrations MUST be
idempotent where possible. The initial migration MUST be applied before the first service start.
Migration 0002 MUST tolerate bootstrap progress rows that predate effective chapters: when a
novel is `pending`, `parsing`, or `error` and has no effective chapter within its advertised count,
the migration MUST preserve the progress row and its preferences without normalization. A
`ready` novel, or a novel whose status is missing or unrecognized, with no effective chapter MUST
fail the migration closed. Normalization MUST use an existing effective chapter and MUST NOT
rewrite progress from a chapter coordinate alone. Replaying migration 0002 MUST preserve rows
already in a valid post-contract state and MUST NOT delete progress data.
Migration 0019 MUST capture whether `public.user_novels` exists before creating
it, while holding the `novels` lock inside its transaction. The first-adoption
decision MUST be transaction-local. Only when the relation was absent may the
migration backfill uploader shelves from existing novels and users. An existing
relation, including an empty shelf, MUST be treated as an adopted shelf state;
replay MUST preserve intentional detachments and MUST NOT repopulate them. This
is not an automatic repair for arbitrary historical partial migrations.
Migration 0021 MUST be one explicit transaction. Its first-adoption backfill
MUST terminally skip pre-contract completed turns before replacing the old
in-progress-only uniqueness rule with a single unresolved-turn authority rule;
replaying 0020 MUST NOT skip a new-protocol `pending` turn.
The unresolved-turn authority index is accepted only when it belongs to
`public.world_turns`, has the exact predicate/columns, and PostgreSQL reports it
unique, valid, ready, and live. Migration replay MUST replace an exact-name but
non-enforcing index, and Narrative readiness MUST fail while any of those
catalog flags is false.
Migration 0024 MUST be one explicit transaction. It MUST add nullable,
chapter-bounded persona-provenance markers to chat turns and Mid/Long memories;
legacy null rows remain retained for export and deletion but MUST remain
ineligible for online prompt, history, replay, consolidation, and retrieval.
Migration 0025 MUST be one explicit transaction. It MUST add a nullable
32-byte world revision to chat turns, preserve null revisions on legacy
completed or failed rows, and convert every legacy null-revision
`in_progress` row to retryable `failed` with
`causal_revision_unavailable` before enforcing that every `in_progress` row
has an exact 32-byte revision. Replaying each migration MUST preserve rows
already in a valid post-contract state.
Before applying migrations 0021, 0024, or 0025, the supported release path MUST
first stop and drain the Narrative Service world-turn producer before candidate
client assets are exposed, then stop and drain the Novel Service persona reader
and Agent Service context/memory consumer.
During the client contract gate, a candidate world action MUST receive a
retryable `5xx` from the stopped producer rather than a terminal validation
response from an older DTO, so its exact recovery key remains retained.
Zero-downtime world actions are not claimed. Only after all three services quiesce may the migrations run, so first-adoption
backfill and activation of the consumer quarantine share one clean release
boundary.
An installation whose active release script predates this drain/barrier logic
MUST use a two-stage managed upgrade: first activate a control-only release
that contains the new release script and every semantic-barrier migration
already present in the active release, but none of the later barriers that
have not yet been applied; then use that script to deploy the next barrier and
matching services. A post-0024 installation adopting 0025 therefore MUST retain
0021 and 0024 in the control-only target and omit only 0025. Initial adoption
through the new script MUST select a release that
already contains all four required migrations (0021, 0024, 0025, and 0030).
Adoption, upgrade, restore, and rollback tooling MUST fail closed rather than
activate a writer or reader whose manifest lacks any required semantic barrier
after that contract is present. Crossing any barrier requires a separately
approved database compatibility procedure.
The release state MUST persist the exact target manifest immediately before
starting the migration phase and MUST clear that transition marker only after
the deployed manifest is durably promoted. A successful storage sync of the
marker MUST precede migration. Promotion and staged-candidate removal MUST be
synced before marker deletion, and that deletion MUST be synced again; a sync
failure MUST leave the marker recoverable. Restore/rollback schema barriers
MUST use this marker, not the mere presence of a downloaded candidate: a
failure before the marker is written may restore the current release, while an
interrupted marked transition MUST NOT activate an incompatible older writer.
Restore MUST roll every marked transition forward to its exact manifest. When
that manifest is not yet `current`, it MUST preserve the former `current` as
`previous` and promote only after migration replay and service health succeed;
when it is already `current`, recovery MUST retain the existing `previous`.
Whenever an upgrade has a former `current`, its rename to `previous` MUST be
successfully storage-synced before the target manifest replaces `current`, so
no crash can make the target durable while losing its rollback authority.
For both initial adoption and upgrade, the fully installed target tempfile MUST
be included in a successful storage sync before it is renamed to `current`, so
a durable directory entry cannot name an unpersisted target file.
If initial adoption is interrupted after the marker is written but before a
`current` manifest exists, restore MUST roll forward the exact marked manifest,
MUST reject a different candidate or an impossible previous release, MUST work
when the candidate file is absent, and MUST promote `current` only after the
idempotent migration and service health checks succeed.
Before normal restore or rollback writes a transition marker, it MUST remove
any unmarked staged candidate. Normal restore and rollback MUST use the same
exact-target marker and durable finalization barrier as adoption and upgrade;
rollback MUST also use the same target-tempfile and previous-manifest promotion
protocol. A legacy rollback marker MAY be recovered for compatibility, but new
rollback operations MUST NOT create a second recovery authority alongside the
schema-transition marker.
The local Docker launchers MUST stop the existing Compose project without
removing named volumes before starting the rebuilt stack. This MUST remove the
old one-shot migration container and quiesce old writers so migrations rerun
before dependent services. The portable desktop runtime MUST stop its named
embedded database, refuse startup while old service ports remain occupied,
apply its embedded migrations through 0025, and only then spawn application
services.
### 12.4 Backup, Restore, and Erasure Replay
The authoritative PostgreSQL database is recoverable under the versioned policy in
[`docs/BACKUP_RESTORE.md`](docs/BACKUP_RESTORE.md). Implementations MUST satisfy:
1. Deleting a user or a novel MUST write a durable erasure record in the same database
transaction as the authoritative delete. The record's identifying payload is limited to
subject type and UUIDs (including the owning user UUID for novels); its operational
bookkeeping fields — deletion time, retained-source marker, re-queue stamp — MUST NOT carry
source text, message content, profile data, or credentials. Records MUST be written for
every deletion path including per-novel records under an account cascade and MUST survive
account and novel cascades.
2. Erasure replay MUST run in the standard migration path before services start, MUST be
idempotent across deployments, MUST remove any subject row matching an erasure record, and
MUST re-queue the deterministic retained-source object key for an erased novel whose subject
row no longer exists exactly once per record within a database lineage, tracked by durable
per-record bookkeeping; a restore starts a new lineage, so it MAY cause at most one
additional re-queue per record.
3. Every database carries exactly one lineage token: a version-4 UUID created by the standard
migration path only when none exists; ordinary deployments preserve it, and only a restore
regenerates it — as a fresh version-4 UUID, atomically with the restored data becoming
reachable, recording the artifact's token as parent (recorded absent for a token-less
artifact), so no crashed or partial restore can present the artifact's token as live.
Backup artifacts MUST embed an erasure-record export taken from the same database snapshot
as the dump, and record that snapshot's covered-through timestamp and the lineage token.
The restore procedure MUST abort when the manifest token and the token inside the verified
dump mismatch or exactly one of them is absent; a wholly token-less artifact MUST restore
through the disaster gate, because an absent token never establishes continuation. The
restore procedure MUST stop application writes before exporting
erasure records, MUST treat a reachable database as live continuation only when its
lineage token equals the artifact's, MUST replay the union of every available erasure
source, MUST abort when sources disagree on a deletion fact of the same subject (the
retained-source marker merges monotonically upward as bookkeeping), and MUST refuse to
complete a restore whose residual window — from the newest source's covered-through
timestamp to the writes-stopped or declared-failure time — is non-empty. The only
sanctioned continuation is attest-or-erase: every restored account not already covered by
a collected erasure record receives a retain-with-listed-novels or erase decision before
completion; an account covered by a collected record is the pre-decided fact replay
enforces — no decision may name it, and the restore records an automatic
`replayed` attestation row for it. Erasure records are written and replayed for every
erase-decided account and every unlisted novel before services start, and each decision or
`replayed` row is durably recorded in the restored database with subject identity,
decision, both residual-window bounds, the verified artifact digest inventory, an operator
identity string, and a timestamp. The deployment MUST NOT contain an undecided subject and
MUST NOT serve any subject covered by a collected or decision-written erasure record. Every restore MUST, after verification passes and before services start, rotate the
JWT secret and delete every persisted refresh token, so no pre-restore session survives.
Replay and attest-or-erase MUST preserve the deletion-path invariants: removing the final
account clears the runtime configuration, and decisions that would leave retained accounts
without an administrator require the operator to designate a retained account as
administrator before completion, recorded with the decisions. Silent resurrection is
prohibited in every case.
4. Backup artifacts MUST be produced by the scripted procedure, encrypted at rest, and verified
against their integrity manifest before any restore changes data; corrupt or unverifiable
artifacts MUST fail closed without side effects.
5. Erasure records are internal operational state: they MUST NOT appear in account export and
MUST NOT contain source text, message content, profile data, or credentials.
---
## 13. Frontend Specification
### 13.1 Architecture
The frontend MUST follow Feature-Sliced Design (FSD) with the following layers:
```
src/
app/ — Application bootstrap, routing, global providers, global CSS
pages/ — Page-level composition components
widgets/ — Self-contained UI blocks with their own data fetching
features/ — User interaction scenarios (import, chat, choose, identity)
entities/ — Business entity models and their API hooks
shared/ — UI kit, API client, utility functions, type definitions
```
### 13.2 Required Pages
| Route | Component | Description |
|---|---|---|
| `/` | `HomePage` | Landing page with product introduction and login/register CTA |
| `/login`, `/register` | `LoginPage` | Sign-in and registration modes |
| `/shelf` | `ShelfPage` | User's novel library with import button and progress indicators |
| `/reader/:novelId/:chapterNum` | `ReaderPage` | Chapter reader with slide-in chat panel |
| `/characters/:novelId` | `CharactersPage` | Character gallery for a novel |
| `/settings` | `SettingsPage` | User profile and identity settings |
First-run setup renders `SetupPage` before authenticated application routing;
it is a state gate rather than a public URL.
The authenticated shared catalog MUST list every `ready` canonical novel,
including novels already on the reader's shelf, and MUST exclude unfinished or
failed imports. Catalog responses MUST NOT expose uploader identity, source
storage locators, import diagnostics, or reader-specific progress and world
state. The shelf UI MUST label already attached novels and prevent a
second attachment. Repeating an attach request for the same reader and novel
MUST leave existing reading progress and deviation mode unchanged. Another
reader can attach that canonical novel without another import or parse.
### 13.3 Required Widgets
- `ChatPanel` — Slide-in panel with SSE-streamed character conversation. MUST support opening
without interrupting the reader's scroll position.
- `BranchChoice` — Modal or inline card presenting narrative choices. MUST block chapter
advancement until a choice is made.
- `CharacterCard` — Avatar, name, role badge, and "Talk" button.
- Shelf import controls — upload/paste plus parsing progress in the existing
shelf flow; no standalone wizard component is required.
- Reader progress — chapter position in the reader header; no standalone
progress component is required.
### 13.4 Visual Theme
The frontend MUST implement the following design tokens:
```css
--color-void: #03040a;
--color-cosmos: #080d1f;
--color-nebula: #0f1535;
--color-stardust: #1a2040;
--color-aurora: #6d28d9;
--color-aurora-light: #8b5cf6;
--color-nova: #06b6d4;
--color-nova-glow: #22d3ee;
--color-starlight: #e2e8f0;
--color-moonbeam: #94a3b8;
--color-comet: #475569;
--font-display: 'Cinzel', serif;
--font-body: 'Inter', sans-serif;
--font-reading: 'Noto Serif SC', serif;
```
The background MUST use a deep space gradient from `--color-void` to `--color-cosmos`. The reading
area MUST use `--font-reading` at a minimum size of 18px with a line height of 1.8.
### 13.5 SSE Client Contract
The frontend SSE client MUST:
1. Generate one UUID v4 per logical turn, send it in `Idempotency-Key`, and keep
the same key for every automatic or manual retry.
2. Open a `POST` request to `/api/chat/:characterId/stream` with the user message in the body.
3. Incrementally decode UTF-8 and SSE frames across arbitrary network chunks.
4. Parse `event: delta` JSON and append `data.content` to the displayed message.
5. Finalize only on `event: done` whose JSON has the matching `turn_id` and
`committed: true`; a bare EOF is an error.
6. On `event: error`, discard partial output, display the error, and retain the
key for an explicit retry.
7. Retry network, 5xx, and `turn_in_progress` failures with exponential backoff
(max 3 retries), clearing partial output before each attempt.
During the rolling upgrade the client MUST also accept the previous unnamed
delta frames and empty `done` event. This compatibility parser does not weaken
the new protocol: once a named v2 event is observed, a structured commit
acknowledgement is required.
The Agent Service MUST derive the user, current chapter, and reader identity from authenticated
server-side state. Browser-supplied context is never authoritative. Requests containing the retired
body `user_id` marker MUST fail before LLM invocation with HTTP 426 and error code
`client_upgrade_required`; the client must refresh before retrying.
---
## 14. Observability
### 14.1 Structured Logging
All services MUST emit structured JSON logs to stdout. Each log entry MUST include:
- `timestamp` (ISO 8601)
- `level` (`debug` | `info` | `warn` | `error`)
- `service` (service name)
- `message` (string)
- `trace_id` (string or null) — propagated from the `X-Trace-Id` request header.
The Gateway and downstream services MUST replace a missing or unsafe trace ID
with a generated UUID. Accepted IDs are 1–128 ASCII letters, digits, hyphens,
underscores, or periods. Each emitted HTTP completion log MUST include method,
matched route template (or `unmatched`), numeric status, and `elapsed_ms`
(time until response headers; streaming bodies can continue afterward).
HTTP 5xx completions use `error`, 429 uses `warn`, and other completions use
`info` when the configured filter allows that level. Completion logs MUST NOT
include raw paths, query strings, headers, request bodies, or user content.
### 14.2 Health Endpoints
Each downstream service MUST expose `GET /health` as a process-liveness probe
and `GET /ready` as a dependency-readiness probe. Liveness returns HTTP 200
while the process can serve requests; readiness returns HTTP 503 whenever a
required dependency is unavailable.
The Gateway MUST expose `GET /live` for its own liveness. Its `/health` and
`/ready` endpoints MUST aggregate downstream readiness and return HTTP 503 if
any required service is unavailable. Gateway probe and metrics endpoints MUST
not require application authentication or consume business rate-limit capacity.
Deployments MAY restrict `/metrics` to their internal monitoring network.
### 14.3 Metrics
Each LLM-calling service exposes Prometheus-compatible metrics at its internal
`GET /metrics`. The public Nginx route MUST remain unavailable. The bounded
`llm-observability-v1` contract includes:
- `novelworld_llm_requests_started_total` and `novelworld_llm_requests_total`
- `novelworld_llm_attempts_total` and `novelworld_llm_retries_total`
- request, attempt, stream-setup, and first-token latency histograms
- `novelworld_llm_usage_reports_total`, `novelworld_llm_tokens_total`, and
`novelworld_llm_billable_tokens_total`
- actual and static per-operation output-token ceilings
LLM labels MUST come from bounded configuration and MUST NOT include prompts,
raw URLs/errors, secrets, principals, or resource identifiers. Cost is derived
at query time from billable token classes and operator-maintained current
provider pricing. User-facing presentation uses CNY when the UI language starts
with `zh`, and USD otherwise; exchange rates are configuration, not embedded
constants. Billable-token series identify the credential that actually paid for
the request only through a stable one-way SHA-256 fingerprint; raw API keys and
user identifiers MUST NOT appear in metrics or Prometheus queries. Administrators
may view usage for the platform credential. A non-administrator may view usage
only after configuring a personal credential, and only for that credential;
falling back to the platform credential does not grant access to platform usage.
The checked-in versioned release policy is the source of truth for H3 budgets.
---
## 15. Security Requirements
- All inter-service communication MUST occur on an internal network not exposed to the public
internet.
- The deployment ingress (Nginx in the current Compose profile) MUST be the
only component with a host-published port, and application traffic MUST pass
through the Gateway behind it.
- JWT secrets MUST be at least 32 characters and MUST NOT be committed to version control.
- Passwords MUST be hashed with bcrypt at cost factor 12 or higher.
- File uploads MUST be validated for MIME type and size before storage.
- Source text and user input passed to an LLM MUST be explicitly delimited and treated as untrusted.
Prompt wording MUST NOT be relied on for authorization or commit validity; model-derived
transitions require bounded schema and domain validation.
- Object storage keys MUST be server-generated under the managed
`source-files/<user_id>/<novel_id>` namespace; uploaded filenames MUST NOT
influence a key.
- Database queries MUST use parameterized statements; string interpolation into SQL is forbidden.
---
## 16. Implementation Guidance
This specification does not duplicate implementation order, dependency advice,
test inventories, or prompt templates. [`AGENTS.md`](./AGENTS.md) owns current
engineering constraints; runtime code owns the exact prompts, validators, and
tests. Roadmap issues define the next approved implementation slice.
---
## Appendix A: Glossary
| Term | Definition |
|---|---|
| Agent | An AI persona derived from a novel character, capable of conversing with readers. |
| Branch node | A chapter that contains a narrative choice point. |
| Canon mode | Deviation mode where the agent strictly follows the source text. |
| Compression pipeline | The process of summarizing short-term memories into mid-term memories. |
| Deviation mode | Reader-configurable setting controlling how strictly the agent adheres to canon. |
| Memory pyramid | The four-layer hierarchical memory system (short, mid, long, permanent). |
| Reader identity | The persona the reader adopts when entering the novel world. |
| World state | The accumulated record of a reader's choices and their consequences. |