Files
lore-engine/docs/14-examples.md
Hermes Agent 7d81a761f9 docs(v1.2): planes as first-class graph nodes (Setting, Plane, EXISTS_IN, REFLECTS, LAYER_OF, ADJACENT_TO, ACCESSIBLE_VIA)
Replaces the v1.1 'world_id is a string' model with a graph-of-planes. Driven by Kay's Q2 ('worlds are planes') and the v1.2 design review.

- 17-planes.md (NEW): the plane taxonomy, the four relation types, Cypher patterns, migration from world_id, open questions
- 01-ontology.md: Plane and Setting as first-class nodes; the 6 plane-relation edge types
- 02-time-model.md: plane-aware time (entity_planes_at_time as the 6th time-aware primitive)
- 08-architecture.md: data flow for plane questions (the 'can I get from X to Y' pattern)
- 11-extensibility.md: how to add custom planes and plane-relations without code
- 12-storage-strategy.md: planes are pure graph (no Postgres/Redis/Qdrant/S3 changes)
- 14-examples.md: example 5 — full Setting + planes + Roland + Asmodeus + LLM tool calls
- README.md: v1.2 entry + doc 17 in the table of contents

The POC rebuild (T10) is the next step: migrate the existing 4 world_id values to Setting/Plane nodes and update the plugin queries to use EXISTS_IN/LOCATED_IN.
2026-06-17 03:17:15 +00:00

30 KiB

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:

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:

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:

template: "thieves_guild_mission"
world_id: "arda_1st_age"
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:

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:

{
  "name": "active_missions",
  "arguments": {
    "faction": "crimson_hand",
    "limit": 10
  }
}

Engine response:

{
  "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

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:

template: "war_campaign"
world_id: "arda_1st_age"
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:

# This is a Postgres-direct ingest, not a DomainEntity ingest.
template: "campaign_event"      # a special template that writes to Postgres
world_id: "arda_1st_age"
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:

{
  "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

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:
{
  "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"):
{
  "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.

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.

# 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

// 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
MERGE (s)-[:HAS_PLANE]->(m:Plane {id: 'mardonari.material'})
SET m.name = 'The Material Plane of Mardonari', m.kind = 'material', m.accessible = true;

MERGE (s)-[:HAS_PLANE]->(d:Plane {id: 'mardonari.voldramir'})
SET d.name = 'Voldramir', 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 (s)-[:HAS_PLANE]->(a:Plane {id: 'mardonari.astral'})
SET a.name = 'The Astral Plane of Mardonari', a.kind = 'transit', a.accessible = false;

MERGE (a)-[:ADJACENT_TO]->(m);

Step 2: A mortal and a god, with their plane presences

// 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

// 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.

// 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.world_id = 'mardonari.material';

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:

{
  "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:

{
  "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).