Files
lore-engine/docs/17-planes.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

308 lines
19 KiB
Markdown

# 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 (via the `setting_id` field on each `Plane`).
- Every entity has zero or more `EXISTS_IN` edges to `Setting` nodes (timeless type-assertion — the slice 6.5 setting filter consumes this edge).
- Every entity has zero or more time-bounded `LOCATED_IN_PLANE` 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 the `setting_id` field on the Plane node (a deliberate simplification — see the "Edge types" section). 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)
### Setting ↔ Plane membership (`Plane.setting_id` field)
Every `Plane` belongs to exactly one `Setting`. The engine
encodes this as a `setting_id` field on the `Plane` node
(rather than as a `HAS_PLANE` edge) because the relation is
single-valued and 1-to-many: one Setting has many Planes,
but each Plane has exactly one parent. The reverse lookup
`planes_in_setting(setting_id)` is O(1) via a backend index.
The conceptual model — "Setting is the root of the plane
tree" — is unchanged; only the storage form differs from
the pure-Cypher shape shown in earlier drafts. The pattern
``(:Setting {id: 'eberron'})-[:HAS_PLANE]->(:Plane {id:
'eberron.material'})`` is the *intent*; the implementation
is ``(:Plane {id: 'eberron.material', setting_id:
'eberron', kind: 'material'})`` plus the reverse index.
### `EXISTS_IN` (any entity → Setting) — timeless type-assertion
This edge asserts that the entity *type* exists in the
setting. It is **not time-bounded**. It answers "is this
entity a member of this setting?" — the cross-setting
filter used by the slice 6.5 read tools.
```cypher
(:Person {name: 'Roland Raventhorne'})-[:EXISTS_IN]->(:Setting {id: 'mardonari'})
(:Person {name: 'The Wanderer'})-[:EXISTS_IN]->(:Setting {id: 'the_wild_dream'})
```
`EXISTS_IN` (entity → Setting) is **many-to-many**. A
plane-transcendent god can belong to multiple settings.
A mortal typically belongs to one. The relation is the
slice 6.5 setting filter's primary mechanism.
The setting → plane direction is **not** `EXISTS_IN`; it
is the `setting_id` field on the Plane. The two relations
have different shapes (an entity can exist in many
settings; a plane belongs to exactly one setting), so the
engine models them differently.
For the v1.1 migration story (the `EXISTS_IN` of an
entity → plane in v1.1 design), the engine separates
"setting membership" (the timeless type-assertion) from
"plane presence" (where the entity was at time T). See
`LOCATED_IN_PLANE` below.
### `LOCATED_IN_PLANE` (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?"
### `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?"
The timeless version: an entity's setting membership +
all planes in that setting. (For v1.2, the direct
entity→plane relation is time-bounded `LOCATED_IN_PLANE`,
not the timeless one — the timeless one is the setting
filter.)
```cypher
// What setting(s) does Asmodeus belong to?
MATCH (p:Person {name: 'Asmodeus'})-[:EXISTS_IN]->(s:Setting)
RETURN s.id
// What planes are in that setting?
MATCH (plane:Plane)
WHERE plane.setting_id IN ['mardonari']
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: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_PLANE {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 **Setting** (not plane). For most nodes this is a single edge. For cross-setting entities (gods, outsiders), it is many. The setting→plane direction is encoded as a `setting_id` field on each `Plane`; no separate edge is needed.
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.
6. **Rename the Postgres `world` table to `setting`.** The v1.1 Postgres schema had a `world` table and a `world_id` foreign key on operational tables (`lore_event`, `trade_log`, `retcon`, `dialogue_log`). Per v1.2, this becomes the `setting` table with a `setting_id` foreign key; add a `kind` enum column (`single_plane` | `multi_plane`) to mirror the v1.2 `Setting.kind` field. See `12-storage-strategy.md`.
7. **Update YAML instance files.** v1.1 YAML ingest used a flat `world_id: "arda_1st_age"` string at the top of each file. v1.2 YAML uses `setting_id: "mardonari"` (or whichever Setting the instance belongs to). The structured `EXISTS_IN` edge is generated from the `setting_id` field at ingest time, pointing to the Setting's primary Material Plane. See `11-extensibility.md` and `14-examples.md` for the renamed YAML schema.
## Open questions and trade-offs
### `EXISTS_IN` (entity → Setting) is many-to-many
Default choice (Q-clarify default): a node can have
`EXISTS_IN` edges to many settings. The trade-off:
- **Many-to-many (default).** A god exists in their home
setting, the Astral (transit), and possibly Material
(mortal world). A mortal exists in one setting but can
be extended to many. Most flexible, but means a "what
settings is this entity in" query can return many rows
and the LLM has to disambiguate.
- **One-to-one.** Each entity has a single primary
setting. If it also exists in others, that is a
`LOCATED_IN_PLANE` edge (time-bounded). More
restrictive, matches D&D "type" thinking ("Aldric is a
Material-Plane creature" → Aldric is in the Material
setting).
- **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.