# Content & Canon Schema — Design - **Date:** 2026-07-11 - **Status:** Design approved; not yet planned/implemented. - **Specimen:** `candidate-town.md` (Duncarrow) at repo root — the example town that drives this schema bottom-up. - **Supersedes/realizes:** the `canon-architecture-direction` memory (the storage architecture agreed in discussion; this doc is the concrete schema built from the specimen). Consumes the disposition-integer model from [`2026-07-10-bounded-npc-conversation-design.md`](2026-07-10-bounded-npc-conversation-design.md) and the canon-log contract from [`2026-07-09-canon-log-schema-design.md`](2026-07-09-canon-log-schema-design.md). --- ## Purpose Lock the **content schema** for authored world canon, using Duncarrow as the specimen. Decide the authoring format, the physical client/server (spoiler) split, and the canon home in the repo. Reconcile the town's named-rung disposition gates with the engine's integer disposition. This is a **schema lock**, not a content-authoring pass. Duncarrow's content correctness is secondary; its *shape* is the deliverable. **Charter alignment:** §2 (code owns state, AI owns text), §4 (proxy hides spoilers/prompts), §6 (bounded NPC dialogue, `available_moves`), §11 (canon log), §16 (repo layout, `/content`). Nothing here introduces a new runtime system — the schema is an **authoring layer over §6**. --- ## Decisions (the lock) ### D1 — Disposition: named rungs are bands over the integer The engine stores disposition as an integer (−100..100); `adjust_disposition` does integer math (clamped ±15; `become_hostile` = −100 floor). The town authors gates as **named rungs**. These meet by defining threshold **bands** over the integer: | Rung | Integer band | |---|---| | hostile | ≤ −45 | | cold | −44 .. −15 | | neutral | −14 .. +14 | | warm | +15 .. +44 | | trusted | ≥ +45 | - The **integer is the runtime truth.** Bands are an evaluation/authoring layer. `adjust_disposition` math is unchanged. - **Code owns the numbers** (one shared constant, single source — same pattern as `palette.gd` for colour). The **bible owns the rung names/order** via `rule.disposition-ladder`. - The band numbers above are a **tunable placeholder** — the model is fixed, the exact cut points get playtested. Everything else in this doc is the lock. - `never` is a real gate that **no band satisfies** — the fact is held but `reveal` never admits it at any disposition. ### D2 — A town "fact" is an engine "topic" (authoring layer over §6) A gated knowledge entry **is** a §6 reveal-topic. `fact_id = topic_id`. The `gate` is the disposition-band condition that admits `reveal(fact_id)` into that NPC's `available_moves`. `never` = held-but-never-admitted. **No new runtime system** — the schema feeds gate logic the client already runs. ### D3 — Authoring format: Markdown bible → build → JSON - **Source of truth:** a readable Markdown doc per town/topic — section headers, prose commentary between blocks — with **fenced YAML blocks** for the structured entries (Duncarrow's exact form, with the blocks formalized as real YAML so parsing is trivial). - **Runtime:** JSON (the client `ContentDB` and the API content resolver already read JSON). A **build step** extracts the YAML blocks and emits JSON. - One source, reads as a bible, machine-read as data. ### D4 — Client/server split: by role, with `secrecy` as a safety check The build sorts each piece by **what it is for**, not by a number: - **Ships in the game** (`content/world`, bundled in the client export): - Public **canon entities** — whole entry (town/place/region/faction/person-record/rule). - The **gate skeleton** of knowledge entries — `id`, `type`, `related`, and each NPC's `knows: [{fact_id, gate}]`. Enough to compute `available_moves`. **No body.** - **Stays on the server** (`content/server`, API-only, excluded from the client export): - The **body** of every knowledge entry (rumor/fact/secret) — it is an NPC knowledge payload, spoken through `/npc/speak`, never rendered by the client. - NPC **persona/voice** bodies and `disposition_notes` (§6: personas are server-only). `secrecy` (0..4) is the author's spoiler rating and becomes a **build-time safety check**, not the router: if a `secrecy:≥3` body lands in a game artifact, **the build fails**. Defense in depth — structural routing does the sort, secrecy lints it. ### D5 — Canon home ``` content/ lore/ # authored Markdown bibles (SOURCE OF TRUTH) canon-roadmap.md # index: authored-vs-pending, sequences the world duncarrow.md # <- candidate-town.md, promoted here world/ # BUILT, ships in the game (client-bundled) duncarrow.client.json server/ # BUILT, api-only (excluded from Godot export) duncarrow.server.json ``` - Authored bibles: `content/lore/*.md`. - Built game artifact: `content/world/` — where the client already reads. - Built server artifact: `content/server/` — the Godot export must **exclude** this subtree (same class of concern as the deferred "Fallback-JSON export packaging" and "spoilers-in-client-bundle" notes; this schema is the trigger to make the split real). --- ## Schema reference ### Common envelope (every entry) ```yaml id: . # namespaced, unique. e.g. town.duncarrow, npc.mera-fenn type: town | place | region | faction | person | rule # canon entities | rumor | fact | secret # knowledge entries status: candidate | canon # only `canon` builds into shipping artifacts secrecy: 0..4 # author's spoiler rating; drives the D4 safety check related: [, ...] # graph links; every id must resolve body: > # prose (routing per D4) ``` ### Two categories **A. Canon entities** — `town/place/region/faction/person/rule`. Public world objects. `secrecy:0`. Whole entry → game (`content/world`). Never appear in a `knows` list; referenced via `related`. **B. Knowledge entries** — `rumor/fact/secret`. Gated, speakable. Atomic, so different NPCs can hold the same entry at different gates. Skeleton → game; **body → server**. `secrecy` free to be 0..4. ### NPC layer An NPC is a `person`-type entry with a knowledge layer, kept **separate** from its world-record `person.*` (a being can have both — e.g. `person.mayor-oswin-crell` the record and `npc.mayor-oswin-crell` the interactive layer). The build distinguishes the two by **id prefix**: `npc.*` is an interactive layer (carries `knows` + `start_disposition`); `person.*` is a public world-record (canon entity, category A). A `person.*` record never carries a `knows` list. ```yaml id: npc.mera-fenn type: person status: candidate secrecy: 0 start_disposition: cold # rung a stranger meets them at (mapped to an integer) related: [town.duncarrow] body: > # persona / voice — SERVER ONLY (§6) knows: - fact: rumor.elves-avoid-the-shrine gate: neutral - fact: rumor.travelers-go-missing gate: warm - fact: fact.crells-guard-acts-alone gate: trusted # reachable ceiling - fact: secret.crell-runs-slave-trade gate: never # not held / never told disposition_notes: > # author-facing — SERVER ONLY ``` - **Client receives per NPC:** `{ id, start_disposition, knows: [{fact_id, gate}] }`. - **Server receives per NPC:** persona `body`, `disposition_notes`, and the resolved fact **bodies** (fed to the `/npc/speak` prompt as the knowledge list). ### Gate logic (the whole of it) `reveal(fact_id)` enters an NPC's `available_moves` **iff**: 1. the NPC `knows` `fact_id`, **and** 2. `gate ≠ never`, **and** 3. `band(current_disposition) ≥ band(gate)`, **and** 4. `fact_id` is not already in `revealed_topics`. This is §6's existing `available_moves` ("reveal if not revealed") enriched with the band-gate check (2–3). No other runtime change. ### The disposition rule entry `rule.disposition-ladder` (type `rule`, canon entity) states the five rung names and their order plus the `never` gate — the **semantic** ladder. The integer band numbers live in **code** (D1), not the bible. --- ## Build & validation The build step (`content/lore/*.md` → `content/world` + `content/server`): 1. **Parse** — extract fenced YAML blocks from each bible; ignore prose commentary. 2. **Resolve & validate (promotion gate)** — refuse to emit unless: - every `knows` `fact_id` resolves to a real knowledge entry; - every `gate` is a legal rung (`hostile/cold/neutral/warm/trusted`) or `never`; - every `related` id resolves; - no id collision; - **secrecy safety check** — no `secrecy:≥3` body appears in a game artifact. 3. **Emit** — only `status: canon` entries build into shipping artifacts; `candidate` entries are dev-only (surfaced, not shipped). 4. **Split** — route each field per D4 into `*.client.json` (game) and `*.server.json` (api-only). Contradiction-with-existing-canon is **author-asserted** (not auto-checkable) — the promotion from `candidate` to `canon` is where a human confirms it. --- ## The Duncarrow specimen (worked example) `candidate-town.md` exercises every part of the schema: - **Canon entities:** `town.duncarrow`, `shrine.the-white-antlers` (place), `person.mayor-oswin-crell` (record), `rule.disposition-ladder`. - **A secret chain** of atomic knowledge entries at rising secrecy (1→4): `rumor.elves-avoid-the-shrine` → `rumor.travelers-go-missing` → `fact.militia-never-investigates` → `fact.crells-guard-acts-alone` → `secret.crell-runs-slave-trade`. - **The witness** (`npc.mera-fenn`, `start_disposition: cold`) whose reachable ceiling is `fact.crells-guard-acts-alone` at `trusted` — she never holds the `secret` itself. The player **infers** the trade; the game never hands it over as a line. - **The control** (`npc.mayor-oswin-crell`) holding the secret at `gate: never` — a live test that the bounded-move vocabulary cannot leak it at any band. - **The texture case** (`npc.harn-blackwood`) with a low-stakes personal fact (`fact.harn-daughter-left`, secrecy 1, `gate: warm`) — tests disposition-gating on something that spoils nothing. Promoting Duncarrow = moving `candidate-town.md` to `content/lore/duncarrow.md`, flipping `status: candidate → canon` per entry as each passes validation. --- ## Out of scope (this schema) - **Acting on** the Crell secret — no quest/faction/positional combat to stop the trade (the specimen's own POC constraint). The witness's ceiling delivers understanding, not a quest. - **Content authoring** beyond the specimen — new towns/NPCs are their own passes. - **The build tool implementation** — this doc specifies its contract; the code is a separate spec → plan → implementation cycle. --- ## Open / deferred - **Band numbers** (D1) — tunable placeholder, settle by playtest. - **`start_disposition` → integer** — a rung maps to a representative integer for the runtime start (e.g. the band's midpoint, or its lower edge). Pick at implementation. - **Existing `fenn.json` migration** — today it crams server persona + client capabilities in one hand-authored file (the flagged debt). The build supersedes it with generated `*.client.json` / `*.server.json`; migrate Fenn when the build lands. - **Canon-log `race` field / class-id enum widening** — tracked in the races & classes spec, not here.