docs(spec): content & canon schema design

Schema lock built bottom-up from the Duncarrow specimen (candidate-town.md):
- disposition rungs as bands over the engine integer (code owns numbers)
- town fact = engine reveal-topic (authoring layer over §6, no new runtime)
- MD bible -> build -> JSON; split by role (game vs server), secrecy as lint
- canon home: content/lore (source) -> content/world + content/server (built)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
This commit is contained in:
2026-07-11 17:00:40 -05:00
parent aeb9ee4a9d
commit db5ee247e7

View File

@@ -0,0 +1,255 @@
# 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: <type>.<slug> # 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: [<id>, ...] # 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 (23). 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.