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.
This commit is contained in:
Hermes Agent
2026-06-17 03:17:15 +00:00
parent c28dc72e00
commit 7d81a761f9
8 changed files with 548 additions and 7 deletions

View File

@@ -4,7 +4,9 @@ A Neo4j-backed world ontology and modular MCP tool surface that lets an LLM reas
Built on top of the existing [GraphMCP-Example](https://git.homelab.local/kaykayyali/GraphMCP-Example) stack (Neo4j + Redis Streams + Go MCP server), with extensions for the things a *world* needs that a *message archive* does not.
**v1.1 (current):** adds polymorphic `DomainEntity` extension model, multi-store storage strategy, and a microservice decomposition plan. See `11-extensibility.md`, `12-storage-strategy.md`, `13-microservice-decomposition.md`, `14-examples.md`.
**v1.2 (current):** adds first-class `Plane` and `Setting` graph nodes with `REFLECTS` / `LAYER_OF` / `ADJACENT_TO` / `ACCESSIBLE_VIA` relations. Replaces the v1.1 flat `world_id` strings with the model from `17-planes.md`. The v1.1 extension model (`DomainEntity`, multi-store) is unchanged.
**v1.1:** adds polymorphic `DomainEntity` extension model, multi-store storage strategy, and a microservice decomposition plan. See `11-extensibility.md`, `12-storage-strategy.md`, `13-microservice-decomposition.md`, `14-examples.md`.
---
@@ -29,6 +31,7 @@ Built on top of the existing [GraphMCP-Example](https://git.homelab.local/kaykay
| 14 | [Worked Examples](docs/14-examples.md) | **v1.1** — three end-to-end examples: thieves-guild missions, war campaigns, black-market economy. |
| 15 | [Related Work](docs/15-related-work.md) | Survey of GraphRAG, Cognee, LightRAG, Generative Agents, IVIE, WikiChat, TKG methods, CoK. With stars, citations, and direct quotes from abstracts. |
| 16 | [Comparison & Critical Thinking](docs/16-comparison.md) | Head-to-head with GraphRAG, Cognee, Generative Agents. Honest assessment of where the Lore Engine is worse. Strategic recommendation. |
| 17 | [Planes of Existence](docs/17-planes.md) | **v1.2** — first-class `Setting` and `Plane` graph nodes, the plane taxonomy (`material` / `reflection` / `transit` / `outer` / `demiplane` / etc.), the four plane-relation edge types, and the migration from the v1.1 `world_id` string. |
## The 30-second pitch

View File

@@ -39,7 +39,8 @@ The world is modeled as a typed, temporal, source-attributed property graph. Thi
| `Deity` | A named god, demigod, or divine being. | `name`, `domain[]`, `alignment`, `symbol` | `Aelar the Patient` |
| `Spell` | A named magical effect. Has a `MagicSystem` parent (see lvl 1). | `name`, `level`, `school` | `Emberlance` |
| `Material` | A specific substance with lore significance (orichalcum, soulglass, etc.). | `name`, `rarity` | `Soulglass` |
| `Plane` (v1.1) | A named plane of existence, separate world, or parallel realm. Per Kay's answer (Q2), the engine supports multi-world/planar structures via a `world_id` namespace on every node. | `name`, `parent_plane` | `The Material Plane`, `The Fade` |
|| `Plane` (v1.2) | A first-class graph node representing a layer of existence (Material, Shadowfell, demiplane, Outer Plane, etc.). Every `Plane` belongs to a `Setting` via `HAS_PLANE` and can have relations to other planes (`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`). See `17-planes.md` for the full model. | `id`, `name`, `kind` (material/reflection/transit/ethereal/outer/inner/demiplane/transcendent), `summary`, `accessible`, `alignment_tendency`, `valid_from`, `valid_until` | `eberron.material`, `mardonari.voldramir` |
|| `Setting` (v1.2) | A campaign/world scope that owns a tree of `Plane` nodes. The "top of the world" in the engine. | `id`, `name`, `summary`, `genres[]`, `canonical_time` | `eberron`, `mardonari`, `default` (legacy alias) |
### New: Player-vs-NPC separation (v1.1)
@@ -149,8 +150,8 @@ All edges are directed, typed, and (where meaningful) carry a temporal validity
| `CONTRADICTS` | LoreSource → LoreSource | no | Two documents that disagree. |
### Magic, culture, language
| Edge | From → To | Time-bound? | Notes |
### Magic, culture, language
|| Edge | From → To | Time-bound? | Notes |
|---|---|---|---|
| `PRACTICES` | Person/Faction → MagicSystem | yes | Actively uses this magic system. |
| `CASTS` | Person → Spell | yes | Specifically casts this named spell. |
@@ -160,6 +161,17 @@ All edges are directed, typed, and (where meaningful) carry a temporal validity
| `BELONGS_TO` | Person → Culture | yes | Cultural identity (not necessarily citizenship). |
| `CULTURE_OF` | Culture → Location/Region | no | The culture's homeland. |
### Planes (v1.2 — first-class graph nodes)
|| Edge | From → To | Time-bound? | Notes |
|---|---|---|---|
| `HAS_PLANE` | Setting → Plane | no | Every plane belongs to exactly one setting. |
| `EXISTS_IN` | any entity → Plane | **no** (timeless type-assertion) | "This entity type exists in this plane." Not where it *is*, but where it *can* be. Many-to-many: a god can exist in many planes. |
| `LOCATED_IN` (plane variant) | any entity → Plane | yes | Where the entity was at time T. Reuses the time-bounded pattern. "Where was Roland in 430 TA?" |
| `REFLECTS` | Plane → Plane | no | Reflection-of-material relationship. `Shadowfell REFLECTS Material`. |
| `LAYER_OF` | Plane → Plane | no | Sub-layer relationship. `Dis LAYER_OF Nine Hells`. Also used for demiplane containment. |
| `ADJACENT_TO` | Plane → Plane | no | Geometric neighbor. Material ↔ Ethereal. Symmetric. |
| `ACCESSIBLE_VIA` | Plane → Spell/Item/Location | no | Reachable by this mechanism. The "can I get there from here" relation. |
### Artifact & lineage
| Edge | From → To | Time-bound? | Notes |
@@ -221,7 +233,7 @@ For the full Cypher that creates the schema, see `08-architecture.md#schema-boot
## What the ontology deliberately leaves out
- **Multi-world / planar.** Planes and parallel worlds get a `Plane` label (added in v1.1, per Kay's Q2), and every node carries a `world_id` namespace. The engine supports them; the world-builder uses them only if the world actually has them.
- **Multi-world / planar.** Planes are first-class graph nodes (v1.2, per `17-planes.md`). Every `Plane` belongs to a `Setting`. Every entity has `EXISTS_IN` edges to its planes (timeless) and time-bounded `LOCATED_IN` edges for "where it was at T." The world-builder uses planes only if the world actually has them; a single-setting, single-plane world is the simplest valid case.
- **Abstractions like "the Will of the People" or "Destiny."** These are not nodes unless the world has a document that treats them as entities. Otherwise the LLM can claim them and the engine has nothing to verify against.
- **In-world physics rules.** "Can a fireball ignite a dragon?" is not in this schema. It's a LLM-narrative question. The engine tells you what the world's books say about fireballs and dragons; it does not compute the outcome.

View File

@@ -193,6 +193,40 @@ Returns a chronologically ordered list of edges of that type involving the entit
All five are documented in detail with signatures in `05-mcp-tools.md`.
## Plane-aware time (v1.2)
A `LOCATED_IN` edge from a `Person` (or any entity) to a `Plane` carries the same `valid_from` / `valid_until` properties as any other time-bounded edge. The pattern is identical:
```cypher
(:Person {name: "Roland Raventhorne"})
-[:LOCATED_IN {
valid_from: "3rd_age.year_428",
valid_until: "3rd_age.year_430"
}]->
(:Plane {id: "mardonari.voldramir", kind: "demiplane"})
```
The same Roland can be on the Mardonari Material Plane (the whole 425-445 span) AND visiting Voldramir (428-430). The two `LOCATED_IN` edges have overlapping but distinct windows.
A sixth time-aware query primitive is added in v1.2:
### 6. `entity_planes_at_time(entity, at_time)`
Returns every plane the entity was on at `at_time` (via `LOCATED_IN`), plus the planes the entity can exist in (via `EXISTS_IN`, the timeless type-assertion). The "where was Roland in 430 TA" question is a single tool call.
The two planes are returned separately:
- `located_in` — the time-bounded set: where the entity was at T.
- `exists_in` — the timeless set: what kinds of planes the entity is a citizen of.
A mortal in a single setting typically has one `EXISTS_IN` edge and one or two `LOCATED_IN` edges. A god has many `EXISTS_IN` edges and may or may not have `LOCATED_IN` edges (gods are not usually *located* in a place; they *exist* in planes).
### Plane-relative vs. setting-relative time
A `valid_from` of `"3rd_age.year_428"` is **setting-relative time** — it is measured in the setting's primary calendar. The Mardonari setting's calendar, Eberron's, the Forgotten Realms' — each setting has its own `Calendar` and `Era` tree. The canonical time string is unique within the setting.
Cross-setting time conversion is **out of scope**. If two settings are connected (a portal, a planar crossing), the world-builder writes a `WHEN_CROSSED` relation that carries the local-time of each side. The engine does not infer it.
## What we deliberately do not do
- **We do not model timezones or relativity.** High-fantasy worlds are not physics engines. If two events are "on the same day" we treat them as concurrent within rounding.

View File

@@ -110,6 +110,47 @@ The Lore Engine is a thin layer on top of the existing GraphMCP-Example stack. S
End-to-end latency target: **<500ms** for a single-tool call, **<2s** for a 3-tool chain.
## Data flow: a plane question (v1.2)
The plane model (per `17-planes.md`) is the new substrate for multi-setting, multi-plane worlds. A question that traverses planes looks like this:
```
1. User → LLM Client
"Can Asmodeus reach the Material Plane from the Nine Hells? And what planes
is the Roland of Mardonari connected to in 430 TA?"
2. LLM Client → LLM (with system prompt + active context)
LLM picks tools: list_accessible_targets(plane='nine_hells'),
entity_planes_at_time(entity='roland_raventhorne', at='3rd_age.year_430')
3. LLM Client → MCP Server (JSON-RPC POST /mcp)
Two tool calls, possibly chained.
4. MCP Server → Neo4j
Cypher for accessibility:
MATCH (start:Plane {id: 'mardonari.nine_hells'})-[:ACCESSIBLE_VIA|ADJACENT_TO*1..2]->(target:Plane)
RETURN DISTINCT target.id, target.kind
Cypher for time-bounded location:
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
5. Neo4j → MCP Server
Combined response: { reachable_planes: [...], rolands_planes_at_430: [...] }
6. MCP Server → LLM Client (JSON-RPC response)
7. LLM Client → LLM
Adds both results to its context.
8. LLM → User
"Yes — Plane Shift (a 7th-level spell) connects the Nine Hells to the Material.
And in 430 TA, Roland was in the Mardonari Material Plane (pottery workshop) and
had recently returned from a 2-year stint in Voldramir (a Mardonari demiplane)."
```
Plane relations (`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`) make these questions a single Cypher traversal instead of a multi-step string-matching exercise.
## Data flow: structured ingestion
```

View File

@@ -391,7 +391,41 @@ That's the architecture. The next two docs decompose it further:
## What this is NOT
- **Not a graph database replacement.** Neo4j stays as the macro world graph. `DomainEntity` is a node label, not a different DB.
- **Not a no-code system.** The world-builder writes YAML, but the engine is still Go. The YAML is data, not code.
- **Not "add anything as a DomainEntity."** Some things are *primitives* and should remain in the core ontology. A `Plane` is a primitive — a first-class graph node with relations. Don't make a plane a `DomainEntity` with a `template_id: 'plane'`. The engine reasons over planes via graph traversal, not via type-template.
- **Not a free-for-all.** The type-template system is for *content* types (missions, campaigns, trade lots, rituals). The *core* types and the *plane* types are the engine's contract; they change via the ontology docs, not via runtime templates.
## Extending the plane taxonomy (v1.2)
The plane model from `17-planes.md` is open-ended: any `kind` value is valid, and any plane-relation type (`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`, or new ones) is just a Cypher edge. The world-builder can add their cosmology's planes without code changes.
### Adding a custom plane
To add a "Realm of Dreams" demiplane to a setting:
```cypher
MERGE (s:Setting {id: 'eberron'})-[:HAS_PLANE]->(p:Plane {id: 'eberron.realm_of_dreams'})
SET p.name = 'The Realm of Dreams',
p.kind = 'demiplane',
p.summary = 'A demiplane entered only through sleep or specific fey magic.',
p.accessible = true,
p.alignment_tendency = 'chaotic_neutral'
```
The engine does not need to know `kind: 'demiplane'` in advance — the value is a string the LLM can reason over. The plugin layer exposes tools like `find_planes_by_kind(kind='demiplane')` that match on the string.
### Adding a custom plane-relation
To add `BORDERS` (a less-common D&D cosmology relation: two planes that share a physical border):
```cypher
MATCH (a:Plane {id: 'eberron.shadowfell'}), (b:Plane {id: 'eberron.feywild'})
MERGE (a)-[:BORDERS]->(b)
```
The plugin's allowlist of relation types is data-driven (per the existing extensibility pattern). The world-builder adds the new relation to the allowlist via the `OntologyRule` mechanism, and the LLM starts seeing it.
### Per-setting customization
The plane taxonomy is per-setting because plane-ids are setting-scoped (`eberron.material` vs `mardonari.material`). A setting that needs more planes just adds them. A setting that doesn't need a Shadowfell doesn't have one. The LLM's `list_planes(setting_id='eberron')` returns whatever exists for that setting.
- **Not a constraint-free system.** The template *defines* the constraints. The engine enforces them at ingest time and the consistency engine enforces them at write time. The LLM sees a typed system; it just doesn't have to write the type definition.
- **Not a workaround for bad schema design.** If a domain really is `Person` or `Faction`, model it as such. The `DomainEntity` wrapper is for things that *aren't* core types.

View File

@@ -27,6 +27,7 @@ Existing GraphMCP-Example has Neo4j + Redis + the LLM proxy. We add **PostgreSQL
The macro world graph. Anything where the LLM will say *"traverse from A"* or *"find all X related to Y"* or *"is X connected to Y?"* — that lives in Neo4j.
- **Core entities:** Person, Faction, Location, Item, Era, Date, Lineage, Culture, Deity, Language, MagicSystem, Title, Region, Material.
- **Planes (v1.2):** `Setting` and `Plane` are first-class graph nodes. Plane relations (`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`) are first-class edges. The `EXISTS_IN` and `LOCATED_IN` relations between entities and planes are stored here too. See `17-planes.md`.
- **Time-bounded relations** between core entities: `RULED`, `MEMBER_OF`, `LOCATED_IN`, `PARTICIPATED_IN`, `ALLIED_WITH`, `POSSESSES`, etc. Always time-bounded. Always queryable via `time_in_window`.
- **Polymorphic domain entities** (`:DomainEntity` with a `template_id`): a thieves-guild Mission, a war Campaign, a Spellbook, a TradeLot, a Ritual. The entity *itself* and its relations to other entities (Person, Faction, Location, other DomainEntities) live in Neo4j.
- **Type templates** (`:TypeTemplate`): the YAML-defined schemas, stored as parsed JSON for the consistency engine and LLM to query.

View File

@@ -566,3 +566,153 @@ The iteration loop drops from weeks to hours. That's the v1.1 win.
- **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
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
```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.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:
```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).

266
docs/17-planes.md Normal file
View File

@@ -0,0 +1,266 @@
# 17 — Planes of Existence: A First-Class Graph Model
> **Status:** v1.2 amendment (replaces v1.1's flat `world_id` strings with graph nodes).
> **Drives:** Q1, Q2, Q3 from the design review (planes as first-class nodes; "worlds are planes"; `EXISTS_IN` is a timeless type-assertion; the four plane-relation edge types).
This document is the new plane model. It supersedes the v1.1 "world_id string namespace" pattern. The change is motivated by the limitations of the flat-string model when faced with D&D-style cosmology: a string can name a plane, but it cannot model the *relations between planes* (Shadowfell reflects Material, Astral is a transit layer, the Nine Hells has nine sub-layers).
After this amendment:
- `Plane` is a first-class graph node.
- A `Setting` is a parent that owns its planes.
- Every entity has zero or more `EXISTS_IN` edges to `Plane` nodes (timeless type-assertion).
- Every entity has zero or more time-bounded `LOCATED_IN` edges to `Plane` nodes (where it was at time T).
- Planes have relations to other planes: `REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`.
- The old `world_id` string property is **deprecated** but kept for read-only compatibility during the migration window.
## The key insight: "worlds are planes"
In v1.1 we used `world_id` as a flat string on every node. The values in the seed data were `default`, `arda_greyscale`, `mardonar`, `voldramir`. The flaw: a "world" was a *single* string with no internal structure, so it could not represent the *relations* between worlds.
In v1.2 the model is rewritten: **a "world" is just the primary Material Plane of a setting.** When a player says "Mardonar," they mean the *Material Plane of the Mardonari setting* — not a separate universe. The same person can be in Mardonar's Material Plane today, in Mardonar's Voldramir demiplane next week, and in the Mardonari setting's Astral transit layer in between. The plane changes; the person does not get duplicated.
This is the D&D-canonical model. It also happens to be what `world_id` was *trying* to express all along, just with the wrong abstraction.
## Node labels (new)
### `Setting`
A campaign, game, or world scope. The top of the world hierarchy. A `Setting` is the unit of "what world are we talking about" — Eberron, the Forgotten Realms, Mardonari, the Iron Kingdoms, a home-brew demiplane-only setting.
| Property | Type | Notes |
|---|---|---|
| `id` | string | Stable slug, e.g. `eberron`, `mardonari`, `default` (legacy alias for the v1 default world). |
| `name` | string | Human-readable, e.g. "Eberron," "Mardonari." |
| `summary` | string | One-paragraph world description. |
| `genres` | string[] | e.g. `["high_fantasy","steampunk","noir"]`. |
| `canonical_time` | string | The current in-fiction time of the campaign. Format: `{era}.{unit}` (see `02-time-model.md`). |
A `Setting` is the *root* of the plane tree. Every `Plane` belongs to exactly one `Setting` via `HAS_PLANE`. A `Setting` with no planes is valid (and represents a world whose plane structure is not yet detailed).
### `Plane`
A layer of existence within or alongside a setting. The engine treats all planes uniformly — there is no special-cased `Material` plane. Planes are typed by their `kind` property, and relations between planes are first-class edges.
| Property | Type | Notes |
|---|---|---|
| `id` | string | Stable slug within the setting, e.g. `eberron.material`, `mardonari.voldramir`, `default.arda_greyscale`. |
| `name` | string | Human-readable, e.g. "The Material Plane," "Voldramir," "Mount Celestia." |
| `kind` | string enum | See the taxonomy below. |
| `summary` | string | Optional one-paragraph description. |
| `parent_plane` | string (Plane.id) | For layered planes (Nine Hells → Dis → Minauros, etc.). |
| `accessible` | boolean | Whether mortals can reach the plane without artifact/spell assistance. False for the Far Realm, true for Material. |
| `alignment_tendency` | string | Optional, e.g. `lawful_good`, `chaotic_evil`, `neutral`. |
| `valid_from` | string | When this plane came into existence. Format: `{era}.{unit}`. |
| `valid_until` | string | When it ceased to exist. `null` for still-active planes. |
## The plane taxonomy (the `kind` enum)
The engine does not hard-code any specific planes. `kind` is a free-form string, but the recommended taxonomy is the D&D-style cosmology:
| `kind` | Meaning | Examples | Notes |
|---|---|---|---|
| `material` | The primary, baseline plane. The world players normally inhabit. | Eberron Material, Mardonari Material, Forgotten Realms Material. | Every setting has one. The "world" colloquially. |
| `reflection` | A plane that mirrors the material plane but with different qualities. | Shadowfell (dark), Feywild (vibrant), the parallel Material of the Arda greyscale seed. | Has a `REFLECTS` edge to a material plane. |
| `transit` | A plane used to travel between other planes. | Astral Plane, Ethereal Plane. | Has `ADJACENT_TO` edges to other planes. |
| `ethereal` | A plane that *overlaps* the material — you can see into both. | The Ethereal Plane. | Distinct from `transit` because the overlap is geometric, not journey-based. |
| `outer` | A plane inhabited by deities, fiends, celestials, or other extraplanar beings. | Mount Celestia, the Nine Hells, the Abyss, Mechanus, the Beastlands, Gehenna. | Grouped by alignment tendency. |
| `inner` | A plane embodying an elemental or quasi-elemental force. | Plane of Fire, Plane of Water, Plane of Earth, Plane of Air, Para-elemental planes. | Often paired. |
| `demiplane` | A small, finite, often personalized plane. | A wizard's pocket dimension, Voldramir, the demiplane created by Mordenkainen's Magnificent Mansion, Ravenloft (a demiplane that imprisons). | Has a `contained_in` (often Material) and is finite in size. |
| `transcendent` | A plane that exists outside the standard cosmology. | The Far Realm (lovecraftian outer), the realm of the gods-as-concept (in settings where "the gods" is a place, not beings). | Reserved for cosmology-bending settings. |
A setting does not need every kind. A low-magic home-brew world might have only `material` + a single `demiplane`. A planes-heavy D&D setting has dozens.
## Edge types (plane model)
### `HAS_PLANE` (Setting → Plane)
Every `Plane` belongs to exactly one `Setting`. This is the root of the plane tree.
```cypher
(:Setting {id: 'eberron'})-[:HAS_PLANE]->(:Plane {id: 'eberron.material', kind: 'material'})
```
### `EXISTS_IN` (any entity → Plane) — timeless type-assertion
This edge asserts that the entity *type* exists in the plane. It is **not time-bounded** (see Q3). It answers "can this kind of thing be in this plane?" not "was it there at time T?"
```cypher
(:Person {name: 'Asmodeus'})-[:EXISTS_IN]->(:Plane {id: 'eberron.nine_hells', kind: 'outer'})
(:Person {name: 'Aldric'})-[:EXISTS_IN]->(:Plane {id: 'eberron.material', kind: 'material'})
(:Person {name: 'Roland Raventhorne'})-[:EXISTS_IN]->(:Plane {id: 'mardonari.material', kind: 'material'})
```
`EXISTS_IN` is **many-to-many**. A god exists in multiple planes (their home, the Astral, possibly Material). A mortal typically exists in one. A plane-transcendent entity exists in many. This is the answer to "how does EXISTS_IN work for entities that exist in multiple planes?" — see "Open questions" below for the trade-off.
The relation is optional. A setting with no `EXISTS_IN` edges from a node means "this node's plane presence is not specified yet" (provisional). The consistency engine has a rule for this: see `04-consistency.md`.
### `LOCATED_IN` (any entity → Plane) — time-bounded
This edge says "the entity was at this plane at time T." It is **time-bounded** via `valid_from` and `valid_until`, exactly like the other time-bounded relations in the engine. It answers "where was Roland in year 312 TA?" not "what kind of plane is Roland from?"
```cypher
(:Person {name: 'Roland Raventhorne'})-[:LOCATED_IN {
valid_from: '3rd_age.year_425',
valid_until: '3rd_age.year_445'
}]->(:Plane {id: 'mardonari.material'})
(:Person {name: 'Roland Raventhorne'})-[:LOCATED_IN {
valid_from: '3rd_age.year_428',
valid_until: '3rd_age.year_446'
}]->(:Plane {id: 'mardonari.voldramir', kind: 'demiplane'})
```
The same Roland can be in Material *and* a demiplane at different times. The Cypher pattern:
```cypher
// Where was Roland in 430 TA?
MATCH (p:Person {name: 'Roland Raventhorne'})-[r:LOCATED_IN]->(plane:Plane)
WHERE time_in_window('3rd_age.year_430', r.valid_from, r.valid_until)
RETURN plane.name, plane.kind
```
### `REFLECTS` (Plane → Plane) — material mirrors
A plane that is a *reflection* of another. Shadowfell reflects Material, Feywild reflects Material, the Arda greyscale "world" reflects Eberron's Material. A reflection is a parallel universe that mirrors the geography of its source but with twisted or inverted qualities.
```cypher
(:Plane {id: 'eberron.shadowfell', kind: 'reflection'})-[:REFLECTS]->(:Plane {id: 'eberron.material'})
(:Plane {id: 'eberron.feywild', kind: 'reflection'})-[:REFLECTS]->(:Plane {id: 'eberron.material'})
```
This is *directed*. Eberron's Shadowfell reflects Eberron's Material, not Forgotten Realms' Material. The plane-id is the disambiguator.
### `LAYER_OF` (Plane → Plane) — sub-planes
A plane that is a *sub-layer* of a larger plane. The Nine Hells is a single Outer Plane, but it has nine layers (Avernus, Dis, Minauros, Phlegethos, Stygia, Malbolge, Maladomini, Cania, Nessus). A layer `LAYER_OF` its parent plane. This is *also* how a demiplane `LAYER_OF` its containing plane if you prefer that model.
```cypher
(:Plane {id: 'eberron.dis', kind: 'outer'})-[:LAYER_OF]->(:Plane {id: 'eberron.nine_hells', kind: 'outer'})
(:Plane {id: 'mardonari.voldramir', kind: 'demiplane'})-[:LAYER_OF]->(:Plane {id: 'mardonari.material', kind: 'material'})
```
A plane can have many layers. A layer can be a parent of further layers (Dis has sub-layers in some cosmologies). The relation is not required — a flat taxonomy is valid.
### `ADJACENT_TO` (Plane → Plane) — geometric neighbors
A plane that is *geometrically adjacent* — you can physically reach it by walking, climbing, swimming, or by being there at the right moment. Material is adjacent to Ethereal (the Ethereal Plane overlaps it). Material is adjacent to the Astral in some cosmologies. Adjacency is the answer to "can I walk there from where I am?"
```cypher
(:Plane {id: 'eberron.material'})-[:ADJACENT_TO]->(:Plane {id: 'eberron.ethereal', kind: 'ethereal'})
(:Plane {id: 'eberron.material'})-[:ADJACENT_TO]->(:Plane {id: 'eberron.astral', kind: 'transit'})
```
`ADJACENT_TO` is symmetric. If Material is adjacent to Ethereal, Ethereal is adjacent to Material. The Cypher pattern uses undirected traversal.
### `ACCESSIBLE_VIA` (Plane → Spell OR Plane → Item OR Plane → Location)
A plane that is reachable *by a specific mechanism*. The most common form is spell: Material is `ACCESSIBLE_VIA` Plane Shift, Astral Projection, or Gate. It is also valid to point at a physical portal: the Nine Hells is `ACCESSIBLE_VIA` the Stygian Gate in Dis.
```cypher
(:Plane {id: 'eberron.nine_hells'})-[:ACCESSIBLE_VIA]->(:Spell {name: 'Plane Shift'})
(:Plane {id: 'eberron.material'})-[:ACCESSIBLE_VIA]->(:Location {name: 'The Stygian Gate'})
```
This is the "is there a way to get there" relation. It is also the LLM-friendly one: "I'm at X, can I get to Y?" becomes a graph traversal.
## The canonical plane taxonomy (a starter set for D&D-style settings)
This is a recommended starting set, not a hard requirement. The engine does not require any of these planes to exist.
| Plane | kind | reflects | adjacent_to | layer_of | notes |
|---|---|---|---|---|---|
| `{setting}.material` | material | — | ethereal, astral | — | Required. |
| `{setting}.shadowfell` | reflection | material | ethereal | — | Optional. Dark mirror. |
| `{setting}.feywild` | reflection | material | — | — | Optional. Vibrant mirror. |
| `{setting}.ethereal` | ethereal | — | material, shadowfell | — | Overlaps material. |
| `{setting}.astral` | transit | — | material, outer planes | — | Transit layer. |
| `{setting}.mount_celestia` | outer | — | astral | — | Lawful good afterlife. |
| `{setting}.nine_hells` | outer | — | astral | — | Lawful evil afterlife. |
| `{setting}.abyss` | outer | — | astral | — | Chaotic evil. |
| `{setting}.mechanus` | outer | — | astral | — | Lawful neutral. |
| `{setting}.beastlands` | outer | — | astral | — | Chaotic neutral. |
| `{setting}.far_realm` | transcendent | — | — | — | Lovecraftian outer. |
The `{setting}` prefix is a convention, not a constraint. A setting with one plane can use the bare name (`material` instead of `eberron.material`).
## Cypher patterns
### "What planes does this entity exist in?"
```cypher
MATCH (p:Person {name: 'Asmodeus'})-[:EXISTS_IN]->(plane:Plane)
RETURN plane.id, plane.kind, plane.name
```
### "Where was this entity at time T?"
```cypher
MATCH (p:Person {name: '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
```
### "What planes are accessible from where I am?"
```cypher
MATCH (me)-[:LOCATED_IN {valid_until: null}]->(here:Plane)
MATCH (here)-[:ADJACENT_TO|ACCESSIBLE_VIA*1..2]->(target:Plane)
WHERE target <> here
RETURN DISTINCT target.id, target.kind, target.name
```
### "What is the demiplane of Voldramir, and what contains it?"
```cypher
MATCH (d:Plane {id: 'mardonari.voldramir'})-[:LAYER_OF]->(parent:Plane)
RETURN d.name AS demiplane, parent.name AS contained_in
```
### "What reflections of Material exist in this setting?"
```cypher
MATCH (material:Plane {kind: 'material'})<-[:REFLECTS]-(reflection:Plane)
WHERE material.id STARTS WITH 'eberron.'
RETURN reflection.id, reflection.kind
```
## Migration from v1.1
The v1.1 design used `world_id: string` on every node as a flat namespace. The v1.2 model replaces that with the `EXISTS_IN` graph traversal. The migration is:
1. **Add `Setting` and `Plane` nodes** for every distinct `world_id` value currently in the graph. `default` becomes `Setting("default")` + `Plane("default.material", kind: 'material')`. `mardonar` becomes `Plane("mardonari.material", kind: 'material')` under `Setting("mardonari")`. `voldramir` becomes `Plane("mardonari.voldramir", kind: 'demiplane')` under `Setting("mardonari")`. `arda_greyscale` becomes `Plane("default.arda_greyscale", kind: 'demiplane')` under `Setting("default")`.
2. **Create `EXISTS_IN` edges** from every entity to its primary plane. For most nodes this is a single edge. For multi-plane entities (gods, outsiders), it is many.
3. **Mark the legacy `world_id` property deprecated** but do not delete it. Plugin queries check for `EXISTS_IN` first, fall back to `world_id` if the new edges are not present (during the migration window).
4. **Update plugins and the seed** to use the new model. The seed script writes the planes; the plugins query by plane.
5. **Remove `world_id` property in v2.0.** After the migration is stable, the string property is dropped. Plugin queries that still reference it are bugs.
## Open questions and trade-offs
### `EXISTS_IN` is many-to-many
Default choice (Q-clarify default): a node can have `EXISTS_IN` edges to many planes. The trade-off:
- **Many-to-many (default).** A god exists in their home plane, the Astral, and possibly Material. A mortal exists in one plane but can be extended to many. Most flexible, but means a "what planes is this entity in" query can return many rows and the LLM has to disambiguate.
- **One-to-one.** Each entity has a single primary plane. If it also exists in others, that is a `LOCATED_IN` edge (time-bounded). More restrictive, matches D&D "type" thinking ("Aldric is a Material-Plane creature").
- **One or more, but at least one.** Same as many-to-many, but missing `EXISTS_IN` is a consistency violation rather than a provisional state.
The default is reversible — it is a Cypher pattern, not a schema migration. If a downstream user (the LLM, the world-builder) finds many-to-many cluttered, switching to one-to-one is a refactor of the seed and a small plugin change. The design note here is: when in doubt, support the more general case and let the LLM narrow.
### Layer-of vs contained-in
A demiplane is *contained in* a plane (Voldramir is contained in Mardonari's Material). The taxonomy uses `LAYER_OF` for both "sub-layer" (Dis is a layer of the Nine Hells) and "contained in" (Voldramir is a layer of Mardonari's Material). An alternative is to add a `CONTAINED_IN` edge, but the dual semantics of `LAYER_OF` ("a sub-plane") cover both. The Cypher remains the same.
### Plane relations that *aren't* in this doc
If the world needs more plane-edge types (e.g., `BORDERS`, `TRADE_ROUTE`, `WAR_WITH`), they are added to the ontology the same way other edge types are. The four in Q4 (`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`) are the recommended starting set.
## What this is *not*
- **Not a renaming of `world_id` to `plane_id`.** The new model is a graph of planes with relations, not a string namespace. The migration is structural, not cosmetic.
- **Not D&D-specific.** The taxonomy is D&D-flavored because the engine's use case is high fantasy, but the model works for any cosmology: the Wheel of Time has its own pattern (the Pattern, the One Power, the Dark One's prison — a `transcendent` plane), Malazan has its warrens, Cosmere has its three realms. The taxonomy is a recommendation, not a constraint.
- **Not finished.** The taxonomy will grow as the engine is used. The kind enum, the relation types, the time-boundedness of `EXISTS_IN` (currently timeless), the migration timeline — all of these are open to revision in v2 based on what the LLM and the world-builder actually do with the model.