From db5ee247e7e3f8c64c4f9b53d58921f4f0d21ffb Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 17:00:40 -0500 Subject: [PATCH] docs(spec): content & canon schema design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- .../2026-07-11-content-canon-schema-design.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-content-canon-schema-design.md diff --git a/docs/superpowers/specs/2026-07-11-content-canon-schema-design.md b/docs/superpowers/specs/2026-07-11-content-canon-schema-design.md new file mode 100644 index 0000000..4ed74f7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-content-canon-schema-design.md @@ -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: . # 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.