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:
255
docs/superpowers/specs/2026-07-11-content-canon-schema-design.md
Normal file
255
docs/superpowers/specs/2026-07-11-content-canon-schema-design.md
Normal 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 (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.
|
||||
Reference in New Issue
Block a user