Files
lore-engine/docs/14-examples.md
Kaysser Kayyali 122ce88295 slice 6 docs cleanup + ADR 0013
Brings the docs into sync with what shipped in
slice 6 (712→761 tests, 7 sub-slices):

  - Setting↔Plane membership is encoded as a
    ``setting_id`` field on the Plane node, not as a
    HAS_PLANE edge. The pure-Cypher pattern in the
    design is the *intent*; the engine stores it
    differently for the 1-to-many reverse-lookup.
    Updated 01-ontology.md, 11-extensibility.md,
    14-examples.md, 17-planes.md accordingly.
  - EXISTS_IN (entity → Setting) is the timeless
    type-assertion; time-bounded planar location is
    LOCATED_IN_PLANE (entity → Plane) with valid_from
    /valid_until. Updated the edge-type table, the
    Cypher examples, and the migration story.
  - 17-planes.md: clarified "EXISTS_IN is many-to-many
    (setting level, not plane level)"; the legacy v1.1
    EXISTS_IN (entity → plane) is folded into
    LOCATED_IN_PLANE for the time-bounded case and
    removed from the timeless one.

  - ADR 0013 — the v1.2 plane-model migration story.
    Documents the 7 locked decisions (Setting/Plane
    first-class, setting_id field encoding, the
    GraphBackend Protocol extensions, the read-tool
    setting filter, the 4 plane-relation edge types,
    the migration's idempotency), what was rejected
    and why, and the consequences for the Neo4j
    parity follow-up.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-19 20:44:04 -04:00

905 lines
38 KiB
Markdown

# 14 — Worked Examples: How Extensions Look in Practice
This document shows, end-to-end, what it looks like to add three new domains to the Lore Engine:
1. **Thieves-guild missions** (Kay's first example)
2. **War-campaign tracker** (Kay's second example)
3. **Black-market economy** (Kay's third example)
Plus a fourth: **NPC secret knowledge** — how the existing NPC tier system integrates with the new template system.
For each, the deliverables are:
- The template YAML (the type definition).
- An instance YAML (the actual data).
- A sample LLM tool call (what the LLM sees and does).
- A sample engine response (what the LLM gets back).
- A note on the consistency rules that fire automatically.
The point of this document is to show that **adding a new domain is a YAML exercise, not a code exercise.** If the examples below feel like cheating, the design is working.
---
## Example 1: Thieves-guild Missions
### Context
The world has a thieves guild called the Crimson Hand, headquartered in Mardsville. The guild runs missions: heists, intelligence operations, assassinations, smuggling runs. The world-builder wants the LLM to be able to reason over the mission log: "What is the Crimson Hand's most profitable operation this year?" "Who is their best operative?" "What missions are currently active?"
The current ontology doesn't have a "Mission" type. The world-builder needs to add one.
### Step 1: Write the template
`./templates/thieves_guild/mission.yaml`:
```yaml
template:
id: "thieves_guild_mission"
version: "1.0"
owner: "kaykayyali"
description: "A mission run by a thieves guild. Has a quest-giver, targets, payout, and outcome. Used by the Crimson Hand and similar organizations."
entity:
label: "DomainEntity"
type_value: "ThievesGuildMission"
discriminator: "thieves_guild_mission"
properties:
- { name: "mission_code", type: "string", required: true, unique: true }
- { name: "target", type: "string", required: true, indexed: true }
- { name: "payout_gp", type: "int", required: true, indexed: true }
- { name: "status", type: "enum", values: ["planned", "active", "completed", "failed", "cancelled"], default: "planned" }
- { name: "risk", type: "enum", values: ["low", "medium", "high", "extreme"], default: "medium" }
- { name: "secrecy", type: "enum", values: ["public", "faction_internal", "guild_internal", "inner_circle_only"], default: "guild_internal" }
- { name: "target_location",type: "string", required: false, indexed: true }
- { name: "started_at", type: "time_ref" }
- { name: "completed_at", type: "time_ref" }
- { name: "summary", type: "text", embedded: true }
temporal: true
# valid_from defaults to started_at; valid_until to completed_at + 30 days
relations:
- { name: "GIVEN_BY", to_type: "Person", cardinality: "exactly_one" }
- { name: "TARGETS", to_type: ["Person", "Faction", "Location", "DomainEntity"], cardinality: "many" }
- { name: "PAID_BY", to_type: "Person", cardinality: "many" }
- { name: "PAID_TO", to_type: "Person", cardinality: "many" }
- { name: "LOGGED_IN", to_type: "DomainEntity", to_discriminator: "trade_log_entry", cardinality: "exactly_one" }
- { name: "PART_OF", to_type: "DomainEntity", to_discriminator: "thieves_guild_campaign", cardinality: "many" }
- { name: "WITNESSED_BY",to_type: "Person", cardinality: "many" }
npc_knowledge:
default_tier: "outer_circle"
field_overrides:
- { field: "payout_gp", min_tier: "inner_circle" }
- { field: "secrecy", min_tier: "inner_circle" }
tools:
- { name: "list_missions", params: ["filter_by", "sort_by", "limit"] }
- { name: "get_mission", params: ["mission_code"] }
- { name: "missions_by_target", params: ["target", "limit"] }
- { name: "missions_in_era", params: ["era", "limit"] }
- { name: "active_missions", params: ["faction", "limit"] }
- { name: "log_mission", params: ["mission_code", "properties", "relations"], write: true }
rules:
- "no-anachronism-participation"
- "secrecy-honors-npc-tier"
- "payout-must-be-positive"
- "completed-missions-have-completed-at"
llm_hints:
- "When asked about a guild's recent activity, list_missions is a strong starting point. Sort by recency unless the user specifies otherwise."
- "Mission secrecy controls who can know what. Don't reveal inner_circle_only details to an outer_circle NPC."
ui:
icon: "🗡️"
color: "#8b0000"
list_columns: ["mission_code", "target", "status", "payout_gp", "completed_at"]
```
That's the entire type definition. The world-builder drops it in `./templates/thieves_guild/`, then:
```bash
curl -X POST http://localhost:9000/admin/templates/reload
# → {"reloaded": 1, "tools_registered": 6, "rules_attached": 4}
```
Six new MCP tools, four consistency rules, registered in the engine. No Go code changed.
### Step 2: Write an instance
`./ingest/crimson_hand_missions.yaml`:
```yaml
template: "thieves_guild_mission"
setting_id: "mardonari"
instances:
- mission_code: "CH-4471"
name: "The Pale Ledger Heist"
target: "merchant_guild_vault"
payout_gp: 500
status: "completed"
risk: "high"
secrecy: "inner_circle_only"
target_location: "mardsville"
started_at: "3rd_age.year_385.month_3"
completed_at: "3rd_age.year_385.month_4"
summary: "Vex the Silent hired a crew of four to infiltrate the
merchant guild's vault in Mardsville and steal the ledger
of debts. The crew succeeded, but one member was captured
and later executed by the city guard."
relations:
- { type: "GIVEN_BY", to: "person_vex_silent" }
- { type: "TARGETS", to: "faction_merchant_guild" }
- { type: "TARGETS", to: "location_mardsville" }
- { type: "LOGGED_IN", to: "trade_log_9821" }
- { type: "PAID_TO", to: "person_crew_member_2" }
- { type: "PAID_TO", to: "person_crew_member_3" }
- { type: "PART_OF", to: "campaign_pale_quarter" }
- mission_code: "CH-4472"
name: "The Whisper Network"
target: "intelligence_network"
payout_gp: 1200
status: "active"
risk: "extreme"
secrecy: "guild_internal"
target_location: "valdorn"
started_at: "3rd_age.year_386.month_1"
summary: "Alessia Dusk is running a long-term operation to map
the Crimson Pact's intelligence network in Valdorn.
Estimated duration: 8 months."
relations:
- { type: "GIVEN_BY", to: "person_alessia_dusk" }
- { type: "TARGETS", to: "faction_crimson_pact" }
- { type: "LOGGED_IN", to: "trade_log_9844" }
```
Ingest:
```bash
curl -X POST http://localhost:9000/ingest/structured \
-F "file=@crimson_hand_missions.yaml"
# → {"instances_written": 2, "relations_written": 14, "duration_ms": 47}
```
### Step 3: LLM uses the new tools
The LLM is having a conversation with the user. The user asks:
> "What missions is the Crimson Hand currently running?"
The LLM's reasoning:
```
Question type: Type 3 (world state at a time).
Tools available: list_missions, active_missions, etc.
Pick: active_missions(faction="crimson_hand")
```
Tool call:
```json
{
"name": "active_missions",
"arguments": {
"faction": "crimson_hand",
"limit": 10
}
}
```
Engine response:
```json
{
"missions": [
{
"id": "mission_4472",
"mission_code": "CH-4472",
"name": "The Whisper Network",
"target": "intelligence_network",
"payout_gp": 1200,
"status": "active",
"risk": "extreme",
"secrecy": "guild_internal",
"target_location": "valdorn",
"started_at": "3rd_age.year_386.month_1",
"given_by": "Alessia Dusk",
"targets": ["Crimson Pact"]
}
],
"total": 1
}
```
LLM response to user:
> "The Crimson Hand has one active mission right now: *The Whisper Network*, a long-term intelligence operation in Valdorn. It was started by Alessia Dusk at the beginning of 386 TA, and is targeting the Crimson Pact's intelligence network. The mission is rated extreme risk and has an estimated duration of eight months. Source: crimson_hand_missions.yaml."
That answer is **not** in the v1 design. The data is in the polymorphic `DomainEntity` wrapper, the tool was auto-generated from the template, the engine composed the answer across Neo4j (the mission) and Postgres (the mission_log step entries). The LLM used one tool call.
### Consistency rules that fire
The four rules attached to the template run on every write:
1. **`no-anachronism-participation`** (core rule) — checks that the people involved in the mission existed at the time. Vex the Silent, the crew members, and Alessia Dusk all have lifespans covering 385-386 TA. **Pass.**
2. **`secrecy-honors-npc-tier`** (template rule) — checks that any NPC who knows about the mission has a tier ≥ the mission's `secrecy` field. The crew is at `outer_circle` tier; the mission is `inner_circle_only`. **Fail** — the rule fires, creates a `:OntologyViolation` node: *"Mission CH-4471 (secrecy=inner_circle_only) has WITNESSED_BY edges to NPCs at outer_circle tier."* The world-builder reviews.
3. **`payout-must-be-positive`** (template rule) — checks `payout_gp > 0`. Both missions pass.
4. **`completed-missions-have-completed-at`** (template rule) — checks that any mission with `status=completed` has a non-null `completed_at`. CH-4471 has it. **Pass.**
The world-builder can see the violation in their morning review queue: "Mission CH-4471 has a secrecy tier mismatch — the crew shouldn't know about it. Either bump the crew's tier or mark the mission as not witnessed by them."
---
## Example 2: War-campaign Tracker
### Context
The world has ongoing wars. The world-builder wants to track campaigns — multi-month military operations involving multiple factions, multiple battles, and shifting territorial control. The LLM should be able to answer: "Where is the Vyr-Crimson war right now?" "What battles has General Aldric won?" "What is the estimated strength of the Crimson Pact's army?"
### Template: `war_campaign.yaml`
```yaml
template:
id: "war_campaign"
version: "1.0"
owner: "kaykayyali"
description: "A multi-faction military campaign. Tracks battles, sieges, troop movements, and territorial changes."
entity:
label: "DomainEntity"
type_value: "WarCampaign"
discriminator: "war_campaign"
properties:
- { name: "campaign_code", type: "string", required: true, unique: true }
- { name: "name", type: "string", required: true }
- { name: "status", type: "enum", values: ["planned", "active", "concluded", "truce", "escalated"], default: "active" }
- { name: "started_at", type: "time_ref" }
- { name: "ended_at", type: "time_ref" }
- { name: "summary", type: "text", embedded: true }
temporal: true
relations:
- { name: "PARTICIPATES", to_type: "Faction", cardinality: "many" }
- { name: "COMMANDED_BY", to_type: "Person", cardinality: "many" }
- { name: "OCCURS_IN", to_type: "Location", cardinality: "many" }
- { name: "INCLUDES_BATTLE",to_type: "DomainEntity", to_discriminator: "battle", cardinality: "many" }
- { name: "CAUSED_BY", to_type: "Event", cardinality: "many" }
tools:
- { name: "list_campaigns", params: ["filter_by", "sort_by", "limit"] }
- { name: "get_campaign", params: ["campaign_code"] }
- { name: "campaign_battles", params: ["campaign_code", "limit"] }
- { name: "campaign_strength", params: ["campaign_code", "faction", "as_of"] }
- { name: "campaign_movements", params: ["campaign_code", "faction", "limit"] }
- { name: "log_battle", params: ["campaign_code", "battle_data"], write: true }
- { name: "log_movement", params: ["campaign_code", "faction", "from", "to", "army_size"], write: true }
rules:
- "no-anachronism-participation"
- "campaign-status-matches-end-date"
- "battle-belongs-to-active-campaign"
```
The interesting bit is `campaign_strength`. It's a *computed* tool — it doesn't return a static field, it queries across stores to give a real-time answer.
When the LLM calls `campaign_strength(campaign_code="VC-001", faction="house_vyr", as_of="3rd_age.year_386.month_5")`, the engine:
1. Neo4j: get the campaign node, get `PARTICIPATES` factions, get `COMMANDED_BY` generals.
2. Postgres: query `campaign_event` for all `army_moved` events involving House Vyr up to the `as_of` time, with their `army_size` and `casualties`.
3. Compose: compute current strength = initial_strength - casualties + reinforcements.
4. Return the number with citations.
The world-builder never has to update a "current strength" field — it's always computed from the event log.
### Instance: a campaign + its events
`./ingest/vyr_crimson_war.yaml`:
```yaml
template: "war_campaign"
setting_id: "mardonari"
instances:
- campaign_code: "VC-001"
name: "The Border Wars"
status: "active"
started_at: "3rd_age.year_380.month_1"
summary: "The ongoing border conflict between House Vyr and the
Crimson Pact, fought primarily over the Northern Reaches."
relations:
- { type: "PARTICIPATES", to: "faction_house_vyr" }
- { type: "PARTICIPATES", to: "faction_crimson_pact" }
- { type: "COMMANDED_BY", to: "person_aldric_raventhorne" }
- { type: "COMMANDED_BY", to: "person_general_kael" }
- { type: "OCCURS_IN", to: "region_northern_reaches" }
```
And the campaign events — these are **not** `DomainEntity` instances. They go directly into Postgres `campaign_event`:
```yaml
# This is a Postgres-direct ingest, not a DomainEntity ingest.
template: "campaign_event" # a special template that writes to Postgres
setting_id: "mardonari"
events:
- campaign_id: "campaign_VC-001"
event_type: "army_moved"
occurred_at: "3rd_age.year_380.month_2"
faction_id: "faction_house_vyr"
location_id: "location_thornwall_keep"
army_size: 5000
outcome: "successful"
- campaign_id: "campaign_VC-001"
event_type: "battle"
occurred_at: "3rd_age.year_380.month_4"
location_id: "location_black_spire_pass"
faction_id: "faction_house_vyr"
opponent_id: "faction_crimson_pact"
army_size: 5000
casualties: 800
outcome: "victory"
- campaign_id: "campaign_VC-001"
event_type: "army_moved"
occurred_at: "3rd_age.year_386.month_3"
faction_id: "faction_house_vyr"
location_id: "location_mardsville"
army_size: 3200
outcome: "in_progress"
```
Postgres is the right place for these because they're high-volume time-series data. The campaign node in Neo4j is the *summary*; the events in Postgres are the *detail*.
### LLM usage
User: "How is the Border Wars going for House Vyr?"
LLM: `campaign_strength(campaign_code="VC-001", faction="house_vyr", as_of="3rd_age.year_386.month_6")`
Engine: computes from the Postgres event log. Returns:
```json
{
"campaign": "VC-001",
"faction": "house_vyr",
"as_of": "3rd_age.year_386.month_6",
"initial_strength": 5000,
"current_strength": 2400,
"casualties_total": 2600,
"reinforcements_received": 0,
"recent_battles": [
{ "name": "Battle of Black Spire", "outcome": "victory", "date": "380.4" },
{ "name": "Siege of Frosthollow", "outcome": "loss", "date": "382.2" },
...
],
"current_position": "Mardsville (advancing)"
}
```
LLM: "House Vyr's campaign is in trouble. They started with 5,000 troops and are down to 2,400 after six years of fighting. They won the opening Battle of Black Spire but lost the Siege of Frosthollow in 382 TA. Their current position is Mardsville, where they appear to be advancing. They haven't received any reinforcements. Sources: vyr_crimson_war.yaml."
This answer combines Neo4j (the campaign node, the faction relations) with Postgres (the event log) and produces a real-time answer. **No static "current strength" field is ever wrong** — it's always computed from the source data.
---
## Example 3: Black-market Economy
### Context
The world has a black market for rare materials: soulglass, orichalcum, dragonbone, dream-essence. The market is controlled by a few mastermind smugglers, with trade logs known only to them. The LLM should be able to reason over the market — but the data should be **access-controlled by NPC tier**. A commoner NPC cannot know who the mastermind smuggler is. A noble NPC can know the market exists but not the prices. Only the inner circle knows the actual trade log.
This is the example that requires the NPC tier system *and* the new template system *and* the access-control rules.
### Template: `black_market_lot.yaml`
```yaml
template:
id: "black_market_lot"
version: "1.0"
owner: "kaykayyali"
description: "A lot of rare or illicit material being traded on the black market."
entity:
label: "DomainEntity"
type_value: "BlackMarketLot"
discriminator: "black_market_lot"
properties:
- { name: "lot_code", type: "string", required: true, unique: true }
- { name: "material", type: "string", required: true, indexed: true }
- { name: "quantity", type: "int", required: true }
- { name: "unit_price_gp", type: "int", required: true, indexed: true }
- { name: "total_price_gp", type: "int", required: true }
- { name: "status", type: "enum", values: ["available", "reserved", "sold", "seized"], default: "available" }
- { name: "secrecy", type: "enum", values: ["market_public", "merchant_knows", "smuggler_inner", "mastermind_only"], default: "smuggler_inner" }
- { name: "summary", type: "text", embedded: true }
temporal: true
relations:
- { name: "SOLD_BY", to_type: "Person", cardinality: "exactly_one" } # the seller
- { name: "SOLD_TO", to_type: "Person", cardinality: "many" }
- { name: "STORED_AT", to_type: "Location",cardinality: "exactly_one" }
- { name: "PART_OF", to_type: "DomainEntity", to_discriminator: "smuggling_network", cardinality: "many" }
npc_knowledge:
default_tier: "outer_circle" # only the smuggler inner circle knows about lots
field_overrides:
- { field: "unit_price_gp", min_tier: "smuggler_inner" }
- { field: "total_price_gp", min_tier: "smuggler_inner" }
- { field: "SOLD_TO", min_tier: "mastermind_only" } # who bought is mastermind-only
tools:
- { name: "list_lots", params: ["material", "status", "limit"] }
- { name: "get_lot", params: ["lot_code"] }
- { name: "market_prices", params: ["material", "as_of"] }
- { name: "log_trade", params: ["lot_code", "buyer_id", "amount"], write: true }
- { name: "smuggler_network", params: ["smuggler_id"], min_tier: "smuggler_inner" }
rules:
- "no-anachronism-participation"
- "lot-must-be-tied-to-seller"
- "buyer-must-be-recorded-on-sale"
- "secrecy-honors-npc-tier"
llm_hints:
- "Black market data is access-controlled. Don't reveal lot details to an NPC whose tier is below the lot's secrecy field."
- "Market prices aggregate: market_prices is the public-safe version; get_lot reveals details only to those who can know."
```
### The access-control flow
This is the part that doesn't exist in v1. The engine uses the NPC tier system plus the template's `npc_knowledge` field to enforce access control.
```
LLM: query_as_npc(npc_name="Garrick the Cooper", question="What's the price of soulglass these days?")
```
The engine:
1. Looks up Garrick. He's a `Person` with `tier: "commoner"`.
2. The LLM is asking about black market data, so the engine checks the relevant template's `npc_knowledge.default_tier`. For `black_market_lot`, that's `outer_circle`. Garrick is a commoner. He doesn't have access.
3. The engine returns:
```json
{
"answer": "no_access",
"required_tier": "outer_circle",
"npc_tier": "commoner",
"reason": "Garrick is a commoner and does not have access to black market data."
}
```
The LLM is told (via the reasoning harness) to **never** return black market data to Garrick, even if the data exists in the graph. Garrick doesn't know. The engine enforces that.
For a different NPC:
```
LLM: query_as_npc(npc_name="Captain Yara", question="What's the going rate for soulglass?")
```
Captain Yara is a `Person` with `tier: "smuggler_inner"`. She can know about the market. The engine:
1. Looks up Captain Yara. `tier: "smuggler_inner"`.
2. The template requires `outer_circle` for the lot list. Yara qualifies. She can also see `unit_price_gp` (requires `smuggler_inner`).
3. The engine calls `market_prices(material="soulglass", as_of="current")`:
```json
{
"material": "soulglass",
"current_price_per_unit_gp": 250,
"lots_available": 3,
"lots_recently_sold": [
{ "lot_code": "BML-0042", "quantity": 5, "sale_price_gp": 1250, "buyer": "[REDACTED - mastermind_only]" }
]
}
```
The buyer is redacted — that field requires `mastermind_only` tier, and Yara is `smuggler_inner`. The LLM sees the redaction and is told not to attempt to identify the buyer.
The whole access-control story is data-defined. The NPC tier system is a single Person property. The template's `npc_knowledge` field defines what each tier can know. The engine enforces it on every tool call. The LLM sees either the data, a redaction, or an access-denied response.
### Postgres tables for trade logs
The black market writes to the `trade_log` Postgres table. Every sale is a row. Only the mastermind smuggler can query it; the LLM sees aggregates.
```sql
SELECT material, AVG(unit_price), COUNT(*)
FROM trade_log
WHERE location_id = 'mardsville' AND occurred_at > '3rd_age.year_385'
GROUP BY material;
```
The `market_prices` tool runs this query, applies NPC tier filtering, and returns a result that respects access control.
---
## Example 4: NPC secret knowledge (showing the tier integration)
This is the simplest example, but it shows how a core engine concept (NPC tier) is *extended* by templates.
The existing `Person.tier` property is a string. The existing `query_as_npc` tool uses it to scope knowledge. In v1.1, templates can declare *their own* tiers and have their own tier-overrides per field.
```yaml
# In a "secret_knowledge" template:
npc_knowledge:
default_tier: "outer_circle"
field_overrides:
- { field: "secret", min_tier: "inner_circle" }
- { field: "method", min_tier: "outer_circle" }
- { field: "target_identity", min_tier: "inner_circle" }
```
The engine combines the Person's tier with the template's tier requirements. A commoner sees only `method`. An outer_circle member sees `method` + `secret`. An inner_circle member sees everything.
This is the same pattern as column-level access control in a database, but expressed as data, and applied at the tool layer where the LLM is.
---
## What this means in practice
For a world-builder, adding a new domain is now:
1. **Day 1: define the template** (~2 hours of YAML).
2. **Day 1 afternoon: write the first few instances** (~30 minutes each).
3. **Day 2: the LLM can already answer questions about it.**
4. **Ongoing: refine the template, add more instances, add more rules.**
Compare to v1 (the original GraphMCP-Example design):
1. **Week 1: design the schema, write the Go code, write the Cypher migration, deploy.**
2. **Week 2: the LLM can answer questions about it.**
3. **Ongoing: write more code for each new query type.**
The iteration loop drops from weeks to hours. That's the v1.1 win.
## What this is NOT
- **Not a generic CMS.** The templates are typed, the engine validates them, and the consistency engine enforces constraints. The world-builder doesn't get to make a freeform database.
- **Not a no-code tool.** Writing the template YAML is a *real skill*. A bad template produces a bad domain. The world-builder still has to think about structure.
- **Not a substitute for the core ontology.** If a concept really is a `Person` or a `Faction`, model it as such. The `DomainEntity` wrapper is for things that *aren't* core types — domains that are *additional* to the macro world.
- **Not a guarantee of query performance.** A template with 10,000 instances of a complex type, queried 100 times per second, will need indexing and possibly denormalization. The engine is fast enough for the world-builder's interactive use; it's not a high-throughput transactional system.
---
## Example 5: Planes of existence (v1.2)
The D&D-style plane model from `17-planes.md` is the engine's substrate for multi-setting, multi-plane worlds. This worked example shows what the data looks like end-to-end: a Setting, a small plane taxonomy, two entities (one mortal, one god), and the time-bounded location of a demiplane visitor.
### Step 1: Define the setting and the planes
```cypher
// Setting
MERGE (s:Setting {id: 'mardonari'})
SET s.name = 'Mardonari',
s.summary = 'A high-fantasy setting focused on craftsmanship and demiplane exploration.',
s.genres = ['high_fantasy', 'low_magic', 'craft_focused'];
// Planes (note: Setting ↔ Plane membership is encoded as
// a `setting_id` field on the Plane node, not as a
// HAS_PLANE edge — see docs/17-planes.md for the rationale)
MERGE (m:Plane {id: 'mardonari.material'})
SET m.name = 'The Material Plane of Mardonari',
m.setting_id = 'mardonari',
m.kind = 'material',
m.accessible = true;
MERGE (d:Plane {id: 'mardonari.voldramir'})
SET d.name = 'Voldramir',
d.setting_id = 'mardonari',
d.kind = 'demiplane',
d.accessible = true,
d.summary = 'A small demiplane of forges and reed-beds. 50 miles across. Reachable only through a specific stone door in the Wheel & Kiln of Mardonar.';
MERGE (d)-[:LAYER_OF]->(m);
MERGE (a:Plane {id: 'mardonari.astral'})
SET a.name = 'The Astral Plane of Mardonari',
a.setting_id = 'mardonari',
a.kind = 'transit',
a.accessible = false;
MERGE (a)-[:ADJACENT_TO]->(m);
```
### Step 2: A mortal and a god, with their plane presences
```cypher
// Roland Raventhorne — a mortal potter from Mardonar's Material Plane
MERGE (roland:Person {id: 'roland_raventhorne'})
SET roland.name = 'Roland Raventhorne',
roland.born = '3rd_age.year_410',
roland.tier = 'public',
roland.culture = 'Mardonari';
// Roland EXISTS_IN the Mardonari Material (timeless type-assertion)
MERGE (roland)-[:EXISTS_IN]->(m);
// Asmodeus — a god of the Nine Hells
MERGE (asmodeus:Person {id: 'asmodeus'})
SET asmodeus.name = 'Asmodeus',
asmodeus.tier = 'mythic',
asmodeus.alignment = 'lawful_evil';
// Asmodeus EXISTS_IN many planes (gods are not plane-bound)
MERGE (asmodeus)-[:EXISTS_IN]->(:Plane {id: 'mardonari.nine_hells', name: 'The Nine Hells of Mardonari', kind: 'outer'});
MERGE (asmodeus)-[:EXISTS_IN]->(a); // Astral
```
### Step 3: Time-bounded location — Roland visits Voldramir
```cypher
// Roland was at his workshop on the Material Plane
MERGE (roland)-[:LOCATED_IN {
valid_from: '3rd_age.year_425',
valid_until: '3rd_age.year_445'
}]->(m);
// Roland visited Voldramir for 2 years
MERGE (roland)-[:LOCATED_IN {
valid_from: '3rd_age.year_428',
valid_until: '3rd_age.year_430'
}]->(d);
```
The same Roland node has two `LOCATED_IN` edges to two different planes at different times. **No node duplication.** In v1.1 this would have been `roland_raventhorne_mardonar` and `roland_raventhorne_voldramir` as two separate Person nodes connected by `MULTIVERSE_COUNTERPART_OF`. The v1.2 model is cleaner.
### Step 4: Plane accessibility — the spell-to-plane link
```cypher
// The "Plane Shift" spell
MERGE (ps:Spell {id: 'plane_shift', name: 'Plane Shift'})
SET ps.level = 7, ps.school = 'conjuration';
// Voldramir is accessible via Plane Shift
MERGE (d)-[:ACCESSIBLE_VIA]->(ps);
// Voldramir is also accessible via a specific location (the stone door)
MERGE (door:Location {id: 'voldramir_stone_door', name: 'The Stone Door in the Wheel & Kiln'})
SET door.kind = 'portal', door.setting_id = 'mardonari';
MERGE (d)-[:ACCESSIBLE_VIA]->(door);
```
### Step 5: LLM question + tool call
```
LLM: "Is there a way to get from Mardonari's Material to Voldramir?"
Tool call: find_accessible_routes(plane='mardonari.voldramir')
Cypher:
MATCH (target:Plane {id: 'mardonari.voldramir'})-[:ACCESSIBLE_VIA]->(via)
RETURN via, labels(via) AS via_type
```
Engine response:
```json
{
"found": true,
"plane": "mardonari.voldramir",
"routes": [
{ "via_type": ["Spell"], "via_name": "Plane Shift", "via_id": "plane_shift" },
{ "via_type": ["Location"], "via_name": "The Stone Door in the Wheel & Kiln", "via_id": "voldramir_stone_door" }
]
}
```
LLM to user: "Yes. Voldramir is reachable two ways: by casting *Plane Shift* (7th-level conjuration), or by physically entering the Stone Door in the Wheel & Kiln of Mardonar. The first is a spell; the second is a physical portal in the Mardonari Material Plane."
### Step 6: The "where was Roland in 430 TA" question
```
Tool call: entity_planes_at_time(entity='roland_raventhorne', at='3rd_age.year_430')
Cypher:
MATCH (p:Person {id: 'roland_raventhorne'})-[r:LOCATED_IN]->(plane:Plane)
WHERE time_in_window('3rd_age.year_430', r.valid_from, r.valid_until)
RETURN plane.id, plane.kind, plane.name, r.valid_from, r.valid_until
```
Engine response:
```json
{
"found": true,
"entity": "roland_raventhorne",
"at": "3rd_age.year_430",
"planes": [
{ "id": "mardonari.voldramir", "kind": "demiplane", "name": "Voldramir", "valid_from": "3rd_age.year_428", "valid_until": "3rd_age.year_430" },
{ "id": "mardonari.material", "kind": "material", "name": "The Material Plane of Mardonari", "valid_from": "3rd_age.year_425", "valid_until": "3rd_age.year_445" }
]
}
```
LLM to user: "In 430 TA, Roland was in two places at once. The `LOCATED_IN` edges overlap: he was at the Material Plane (the whole 425-445 span) and at Voldramir (428-430). The engine returned both because the time window matches. The narrative answer is: 'In 430 TA, Roland was on an extended visit to Voldramir; he returned to the Material Plane that same year.'"
### What this shows
- **One node, many planes.** Roland is one Person. The v1.2 model says "where he *can* be" (EXISTS_IN) and "where he *was* at T" (LOCATED_IN) as separate, queryable concerns.
- **Planes are relations, not strings.** The `REFLECTS` / `LAYER_OF` / `ADJACENT_TO` / `ACCESSIBLE_VIA` edges make D&D-style cosmological reasoning a single Cypher traversal. No string parsing.
- **The LLM gets clean answers.** Both questions above return structured JSON with plane names, kinds, and time bounds. The LLM composes the natural-language answer from the structured data.
For the design rationale, see `17-planes.md`. For the underlying ontology, see `01-ontology.md` (`Plane` and `Setting` rows in the node-label table, and the `Planes (v1.2)` section in the edge-type table).
## Example 6: Cognee integration walkthrough
A world-builder's first session with the Lore Engine on Cognee. The full path from "I have a markdown chapter" to "Claude answers a time-bounded question about it." This is what `09-roadmap.md#phase-0` validates in the spike.
### Step 1: Stand up Cognee with the Lore Engine extension
```bash
# 1. Pull the Cognee image with the Lore Engine extension baked in
docker pull ghcr.io/topoteretes/cognee:latest-lore-engine
# 2. Start the stack
docker-compose up -d
# - cognee-mcp (port 8000, the MCP server)
# - cognee-storage (Neo4j 5.x, the graph)
# - cognee-postgres (the metadata + operational tables)
# - cognee-vector (pgvector, the embeddings)
# 3. Verify the Lore Engine extension registered
curl -X POST http://localhost:8000/mcp -d '{
"jsonrpc": "2.0", "id": 1,
"method": "tools/list", "params": {}
}'
# → returns 45 tools (8 Cognee + 37 Lore Engine)
# ... including was_true_at, list_lineage, lookup, state_at, ...
```
The Lore Engine extension is a Python package installed in the Cognee image. At startup it registers the 36 typed labels, the constraints, the indexes, and the 37 domain tools.
### Step 2: Ingest a structured YAML file
The world-builder has written `family_tree.yaml` for House Vyr. The Lore Engine's structured parser handles it directly — no LLM involved.
```bash
# Two ways to ingest: Cognee API or the Lore Engine's dedicated endpoint
curl -X POST http://localhost:8000/ingest/structured \
-F "file=@family_tree.yaml" \
-F "source_type=family_tree"
# OR equivalently:
curl -X POST http://localhost:8000/mcp -d '{
"jsonrpc": "2.0", "id": 2,
"method": "tools/call",
"params": { "name": "ingest_structured", "arguments": { "file": "family_tree.yaml" } }
}'
```
The Lore Engine's YAML parser:
1. Validates the YAML against the `family_tree.yaml` schema.
2. Emits `MERGE (p:Person {id, name, lifespan: {from, until}})` for each ancestor.
3. Emits `MERGE (p)-[:PARENT_OF {valid_from, valid_until}]->(c)` for each relation.
4. Tags the `LoreSource` with `source_type: family_tree`.
5. Notifies the consistency pipeline: "new edges added, run the lineage-continuity rule."
Total time: <1 second for a 200-person family tree.
### Step 3: Ingest prose via Cognee's pipeline
For markdown chapters and dialogue, the path goes through Cognee's `add` + `cognify` pipeline. The Lore Engine registers a custom extraction prompt that emits its 36 typed labels instead of Cognee's default `Entity`/`DataPoint` types.
```python
import cognee
# World-builder's Python session
await cognee.add("chapters/aldric_origin.md") # raw markdown chunk
await cognee.cognify() # extract + embed + index
# Behind the scenes:
# - Cognee chunks the markdown (512-token windows, 64-token overlap)
# - Cognee calls the LLM with the Lore Engine's extraction prompt
# - The LLM returns typed triples: (Person "Aldric Raventhorne")-[:RULED]->(Location "Thornwall Keep")
# - Cognee stores the triples in Neo4j with the Lore Engine's labels
# - Cognee embeds the chunk and indexes it in the vector store
# - The Lore Engine consistency pipeline runs anachronism + contradiction checks
```
The extraction prompt is the Lore Engine's contribution: it tells the LLM "emit nodes with one of these 36 labels, edges with one of these 70+ edge types, all with time bounds where applicable." Cognee handles the chunking, embedding, and storage; the Lore Engine defines the schema the LLM emits into.
### Step 4: Ask a time-bounded question
The LLM client (Claude, gpt-4, etc.) is connected to the Cognee MCP server with the Lore Engine's 45-tool surface and the reasoning-harness system prompt from `07-reasoning-harness.md`.
```
User: "Did House Vyr hold the Crimson Throne during the Second Age?"
→ Claude picks tool: was_true_at(relation="RULED", subject="House Vyr", object="Crimson Throne", at_time="2nd_age")
→ POST /mcp { "method": "tools/call", "params": { "name": "was_true_at", "arguments": { ... } } }
→ Cognee MCP server dispatches to the Lore Engine's was_true_at.py handler
→ Handler composes Cypher:
MATCH (f:Faction {name: "House Vyr"})-[r:RULED]->(t:Throne {name: "Crimson Throne"})
WHERE time_in_window("2nd_age", r.valid_from, r.valid_until)
RETURN r, r.valid_from, r.valid_until, r.sources
→ Cognee executes on Neo4j, returns the time-bounded edge
→ Handler formats the JSON response with source attribution
LLM gets: {
"was_true": true,
"valid_from": "2nd_age.year_120",
"valid_until": "2nd_age.year_340",
"sources": ["chronicles-vyr.md", "crimson-throne-succession.md"],
"confidence": 0.94
}
→ Claude to user: "Yes — House Vyr held the Crimson Throne from 120 TA to 340 TA,
which covers the entire Second Age. Sources: the chronicles of House Vyr and
the Crimson Throne succession records."
```
The end-to-end latency: <500ms for a single-tool call.
### Step 5: Add a new domain type (template-driven)
The world-builder wants to track heists for the Crimson Hand thieves' guild. They write a template:
```yaml
# templates/thieves_guild/mission.yaml
template: "thieves_guild_mission"
version: 1
fields:
- { name: mission_code, type: string, required: true, unique: true }
- { name: target, type: string, required: true }
- { name: payout_gp, type: int, required: true }
- { name: status, type: enum, values: [planned, active, completed, botched] }
- { name: risk, type: enum, values: [low, medium, high, extreme] }
- { name: secrecy, type: enum, values: [public, guild_internal, inner_circle_only] }
relations:
- { name: GIVEN_BY, to: [Person, NPC] }
- { name: TARGETS, to: [Location, Faction, Person] }
- { name: LOGGED_IN, to: [DomainEntity] }
tools:
- { name: list_missions, params: [faction, location, since, sort_by, limit] }
- { name: get_mission, params: [mission_code] }
- { name: add_mission, params: [mission_code, target, payout_gp, ...] }
rules:
- "A mission in status=completed must have a completed_at timestamp."
- "A mission in status=botched must have at least one casualty recorded."
```
They hit the reload endpoint:
```bash
curl -X POST http://localhost:8000/admin/templates/reload
# → Cognee data-pipeline picks up the file change
# → YAML validates
# → TypeTemplate node written to Neo4j
# → 3 new tools auto-registered: list_missions, get_mission, add_mission
# → 2 new ontology rules attached to the consistency engine
# → Hot-reload notification: "Domain type thieves_guild_mission registered; 3 new tools available"
```
**No code change. No Cognee restart. No Lore Engine restart.** The LLM's next `tools/list` call sees the 3 new tools.
### Step 6: Use the new tools
```
User: "What heists has the Crimson Hand run in Mardsville this year, sorted by payout?"
→ Claude's reasoning: "The user wants list_missions with a filter on location and a sort."
→ Claude picks tool: list_missions(faction="crimson_hand", location="mardsville", since="1_year_ago", sort_by="payout_gp", limit=5)
→ Cognee MCP server dispatches to the template-generated tool runner
→ Runner looks up the TypeTemplate, generates the Cypher from the spec, executes
→ Returns: [ { mission_code: "M-4471", name: "The Pale Ledger Heist", payout_gp: 500, status: "completed" }, ... ]
LLM to user: "Five heists, led by The Pale Ledger Heist at 500gp (completed) and ..."
```
### What this example shows
- **Cognee is the substrate.** Storage, extraction, embedding, sessions — all Cognee.
- **The Lore Engine is the domain layer.** Typed labels, time model, consistency rules, 45 tools.
- **Structured YAML is exact.** `family_tree.yaml` ingests without an LLM.
- **Prose is fuzzy but typed.** The extraction prompt emits the 36 labels; Cognee chunks and embeds.
- **Templates extend the engine without code.** A YAML file, a hot-reload, three new tools.
- **The LLM gets typed, source-attributed answers.** Every claim traces to a document, every edge has time bounds, every time-aware query uses `time_in_window`.
This is the system the Cognee spike (Phase 0 in `09-roadmap.md`) validates end-to-end. If this walkthrough works against a real Cognee + Lore Engine + Claude stack, the substrate decision is right and the v1 build can begin.