# Canon Log Schema & Origin Seeds — Design

**Date:** 2026-07-09
**Status:** approved (design); implementation plan to follow
**Charter refs:** §2 (code owns state), §7 (Luck), §9 (companions/humiliations), §11 (canon log), §14 (cost/latency), §16 (layout)

## Problem

The canon log (§11) is the compact structured fact list code maintains and injects
into every AI call. It is the spine that stops the AI contradicting itself, and
§11 warns it is "nearly impossible to retrofit." It must be designed before any
call site depends on it.

Concurrently, the player should be able to select one of several **starting
origins**, each with its own starting location and situation — for replayability
without multiplying authoring work.

## Reframe: origin = initial-state seed over a static world

Rather than "multiple maps," an origin is an **initial-state overlay** on a single
shared, static world. The world (one map, its towns, their fixed NPCs and
personalities/knowledge) is authored once. An origin only seeds *where the player
starts and the situation they are in*. This keeps charter §17 scope intact (one
world authored) while raising replayability (N thin origin seeds).

Confirmed scope: **schema supports N origins; the POC authors one.** An origin
seeds: starting location, opening situation, initial dispositions, initial
inventory/items, initial quest/objective, and player build constraints.

## Architecture — three layers, one direction of flow

```
WORLD CONTENT (static)   /content/world      map · npcs(+knowledge) · quests · items
        │ referenced by stable string id
ORIGIN SEED (thin, N)    /content/origins    location + situation + dispo/quest/item/build seeds
        │ new-game construct (origin + world + character creation)
CANON LOG (runtime)      code-owned, saved   the §11 structure, injected into every AI call
```

Content and seed are **immutable inputs**; the canon log is the **single mutable
product**. Nothing writes back up. This is §2 (code owns state) made concrete.

### The in/out boundary

What the canon log carries vs. what it must never carry:

| In the log (narrative context → AI) | Not in the log |
|---|---|
| Player name, class, **luck _descriptor_** | Numeric Luck value — §7: AI must never be able to calculate it |
| Current location (id + name) | Numeric stats, HP/MP, combat state — combat engine owns these |
| Party dispositions | Full inventory contents — game state; only narrative items become facts |
| Rolling recent events (≤5) | NPC knowledge lists — §6, injected only into that NPC's call |
| Established facts (harvested `[FACT]`) | Available moves — §6, injected only into the NPC call |
| Active quests | |
| Humiliations (Banter, §9) | |

Rationale: §7 requires the player to *feel* cursed but never *calculate* it, so the
numeric Luck physically cannot enter the AI payload — only `luck_descriptor` does.
Keeping stats/HP/inventory out keeps the payload small (§14) and separates combat
state from narrative context.

## Canon log schema (runtime state)

```json
{
  "schema_version": 1,
  "player": {
    "name": "Aldric",
    "class_id": "sellsword",
    "luck_descriptor": "Fortune spits on you"
  },
  "location": { "id": "greywater_docks", "name": "the Greywater docks" },
  "party": [
    { "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 },
    { "id": "cadwyn_vell",   "name": "Cadwyn Vell",   "disposition": 15 }
  ],
  "recent_events": [
    "Arrived at Greywater by barge before dawn",
    "Brannoc recognised the harbourmaster and went quiet"
  ],
  "established_facts": [
    "the eastern bridge out of Greywater is washed out",
    "the harbourmaster is named Oda Fenn"
  ],
  "active_quests": [
    { "id": "find_the_ledger", "name": "The Missing Ledger",
      "status": "active", "objective": "Find who took Fenn's ledger" }
  ],
  "humiliations": [
    { "id": "h_0001", "text": "vomited on a shrine step in front of a priest",
      "weight": 7, "turn": 3 }
  ]
}
```

| Field | Type | Notes |
|---|---|---|
| `schema_version` | int | Migration handle; saved logs upgraded on load. |
| `player.name` | string | From character creation. |
| `player.class_id` | enum | `sellsword` \| `assassin` \| `priest` (POC, §8). Id, not display name. |
| `player.luck_descriptor` | string | **Only** Luck representation in the log (§7). Recomputed when numeric Luck drifts. Never the number. |
| `location` | `{id, name}` | id → world content; name → prose. |
| `party[]` | `{id, name, disposition}` | Companions. `disposition` int −100..100 (§9). |
| `recent_events[]` | string[] | Rolling window, **cap 5** (§11). Code writes each terse line, drops oldest. |
| `established_facts[]` | string[] | Durable. Harvested from `[FACT: …]` (§11) + origin seed. Deduped. Uncapped. |
| `active_quests[]` | `{id, name, status, objective}` | `status`: `active` \| `complete` \| `failed`. Definitions in world content. |
| `humiliations[]` | `{id, text, weight, turn}` | Banter memory (§9). **Append-only, stacks.** `weight` 1–10; `turn` drives decay of reference frequency. |

Decisions:
- Every entity carries both `id` (code keys/validates) and `name` (prompt reads) —
  denormalized on purpose so the language model gets natural nouns and code gets
  stable keys.
- `recent_events` cap = 5 (§11 says "3–5"; ceiling taken). Hard rolling window.
- `established_facts` uncapped — dropping a fact reintroduces the exact
  contradiction §11 exists to prevent. If size bites (§14), fix is summarisation,
  not truncation.
- No global `npc_dispositions`; an NPC's live disposition rides only in its
  `/npc/speak` call (§6). Add later if the Narrator needs town-wide stances.

## Origin seed schema

Lives in `/content/origins/<id>.json`. All heavy nouns are id references into
world content; the origin owns only starting values.

```json
{
  "schema_version": 1,
  "id": "deserter",
  "display_name": "The Deserter",
  "description": "You walked away from a company that doesn't allow walking away.",
  "start_location_id": "greywater_docks",
  "situation": [
    "Arrived at Greywater by barge before dawn, hood up",
    "Down to your last coin and owed a favour you can't repay"
  ],
  "opening_facts": [
    "the player deserted the Iron Kettle mercenary company",
    "a bounty notice for the player circulates in the northern towns"
  ],
  "disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
  "inventory_grants": [
    { "item_id": "worn_shortsword", "qty": 1 },
    { "item_id": "coin", "qty": 3 }
  ],
  "start_quest_id": "find_the_ledger",
  "build_constraints": {
    "allowed_classes": ["sellsword", "assassin", "priest"],
    "luck_modifier": 0
  }
}
```

| Field | Feeds | Notes |
|---|---|---|
| `id` / `display_name` / `description` | Origin-select screen | `description` is authored flavour shown at pick time. |
| `start_location_id` | `location` | Must resolve to a world location, else new-game fails validation. |
| `situation[]` | `recent_events` | Opening framing — why you're here, now. |
| `opening_facts[]` | `established_facts` | What is already canon in this origin. |
| `disposition_overrides{}` | `party[].disposition` (companions) / game-state NPC store (world NPCs) | Absolute starting values; omitted → world default. Companion ids land in the log; world-NPC ids land in game state and surface only at that NPC's `/npc/speak` call (§6) — the log has no global NPC-disposition field. |
| `inventory_grants[]` | game-state inventory | Not the log — inventory is state (§2). Narrative items may also seed a fact. |
| `start_quest_id` | `active_quests` | Nullable; resolved against world quest defs. `null` → no active quest. |
| `build_constraints{}` | character creation | `allowed_classes` gates the picker; `luck_modifier` feeds Luck gen. Acts *before* the log exists. |

`humiliations` is never seeded — earned in play only (§7/§9).

## New-game construction

```
1. Player picks origin
     load /content/origins/<id>.json → validate every id resolves in world content
2. Apply build_constraints → character creation
     class picker limited to allowed_classes
     Luck generated per §7 (5-roll average) + luck_modifier
3. Player creates character (name, class, rolled stats + Luck)
4. Construct initial canon log:
     player            ← creation (name, class_id) + luck→descriptor
     location          ← start_location_id (+ name from world)
     party             ← world default roster, companion dispositions ← disposition_overrides
     (world-NPC dispositions from disposition_overrides → game-state NPC store, not the log)
     recent_events     ← situation[]
     established_facts ← opening_facts[]  (+ world always-true facts, if any)
     active_quests     ← resolve(start_quest_id)   (or [])
     humiliations      ← []   (always empty)
     schema_version    ← 1
     inventory_grants  → applied to game-state inventory (NOT the log)
5. Log handed to the game loop; first Narrator call fires with it.
```

Construction reads all three layers and writes only the log. `build_constraints`
firing at step 2 (before the log exists at step 4) is why an origin is a distinct
schema, not a partial canon log: Luck is rolled during creation with the origin's
modifier, and only its *descriptor* reaches the log. Validation is front-loaded at
step 1 — a broken origin fails at new-game, loudly, not three scenes later.

## Maintenance — turn-to-turn evolution

Only code mutates the log. AI prose is never the source of truth (§2/§11); a
`[FACT]` tag is a *request* to record, and code records the canonical string that
later calls read.

| Trigger | Mutation | Rule |
|---|---|---|
| Any role emits `[FACT: …]` | Append to `established_facts` (dedup) | §11 harvest; only way facts enter. |
| A narrative beat resolves | Append terse line to `recent_events`, drop oldest past 5 | Code writes the line, not the AI. |
| Validated `[ADJUST_DISPOSITION: ±n]` (§6) / approval change (§9) | Update `party[].disposition`, clamp −100..100 | Only after the move passes validation; invalid dropped. |
| Event with embarrassment weight ≥ threshold | Append `{id, text, weight, turn}` to `humiliations` | Append-only, stacks forever (§9). |
| Numeric Luck drifts (§7) | Recompute `player.luck_descriptor` | Number stays in game state; only descriptor mirrored. |
| Quest state changes | Update `active_quests[].status` / objective | Definitions in world content; log tracks live status. |
| Player moves | Update `location` | id + name from world content. |

### Per-role injection

The canon log is the shared base of every call; roles add role-specific extras.
The log never grows per-role fields.

| Role | Log + … |
|---|---|
| Narrator | log only |
| NPC | log + this NPC's persona, `knowledge[]`, `available_moves[]` (§6) |
| Improviser | log + the whitelisted event-change set (§7 validator) |
| Banter | log, with `humiliations` as primary source (§9) |
| Adjudicator | log + the current legal action set |

## Storage & format

```
/content/
  origins/        origin seeds (authored) + (later) validated against origin.schema.json
  world/          static, id-referenced content
    locations/  npcs/  quests/  items/
  fallback/       degraded-DM text (§13) — NOT world content, no ids resolved against it
/docs/
  canon-log.md            living contract: this schema, field tables, example
  schemas/
    canon-log.schema.json  JSON Schema — validated on both client and api
    origin.schema.json     JSON Schema — origin seed
save file → serialized canon log (JSON) + game state (Luck number, inventory, stats)
```

- **JSON everywhere.** The log crosses the HTTP boundary to `/api` and seeds
  prompts. The client (GDScript) models it as typed objects and serialises; `/api`
  (Python) validates against the same `canon-log.schema.json`. One schema, two
  languages, no drift.
- **The log is part of the save.** Load a save → same log → same injected context.
  Pairs with §10's per-encounter seeding: reproducible AI context, not just
  reproducible combat. §11 rejected "last N messages"; persisting the *structured
  log* keeps the anti-amnesia guarantee across save/quit.
- **Schemas live in `/docs/schemas/`** — cross-cutting contracts both sides consume
  (§16 doc split). Prose spec beside them in `/docs/canon-log.md`.

## Consequences / follow-ups

- `fallback/` stays outside `world/` (category: authored prose, not id-referenced).
- Implementation will create the two JSON Schemas, the construction routine, the
  maintenance hooks, and one authored POC origin + minimal world content to
  exercise new-game end-to-end.
- Out of scope here: the AI role prompts themselves, combat state, and the save
  system's non-log portions. This spec defines the data contract they all consume.

## Open questions

None blocking. Thresholds (humiliation `weight` cutoff, Banter decay curve) are
tuning values, deferred to implementation.
