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>
This commit is contained in:
@@ -41,8 +41,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.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) |
|
||||
| `Plane` (v1.2) | A first-class graph node representing a layer of existence (Material, Shadowfell, demiplane, Outer Plane, etc.). Every `Plane` belongs to exactly one `Setting` (via the `setting_id` field on the Plane; reverse lookup via `planes_in_setting(setting_id)`) and can have relations to other planes (`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`). See `17-planes.md` for the full model. | `id`, `name`, `setting_id`, `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`, `kind`, `current_era`, `schema_version`, `created_at`, `summary`, `genres[]`, `canonical_time` | `eberron`, `mardonari`, `default` (legacy alias) |
|
||||
|
||||
### New: Player-vs-NPC separation (v1.1)
|
||||
|
||||
@@ -167,14 +167,21 @@ All edges are directed, typed, and (where meaningful) carry a temporal validity
|
||||
|
||||
| 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?" |
|
||||
| `EXISTS_IN` | any entity → Setting | **no** (timeless type-assertion) | "This entity type exists in this setting." The reverse lookup `setting_entities(setting_id)` resolves the membership. The slice 6.5 read-tool `setting=` filter consumes this edge. |
|
||||
| `LOCATED_IN_PLANE` | 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. |
|
||||
| `LAYER_OF` | Plane → Plane | no | Sub-layer relationship. `Dis LAYER_OF Nine Hells`. Also used for demiplane containment. Direction: the layer points at the parent. |
|
||||
| `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. |
|
||||
|
||||
> **Note:** Setting ↔ Plane membership is encoded as a `setting_id`
|
||||
> field on the `Plane` node, not as a `HAS_PLANE` edge. This is a
|
||||
> simplification — every Plane has exactly one Setting, so a
|
||||
> single field is sufficient. The reverse lookup
|
||||
> `planes_in_setting(setting_id)` is O(1) via the
|
||||
> `planes_by_setting` index. The Cypher pattern from
|
||||
> `17-planes.md` is preserved; only the storage form changed.
|
||||
|
||||
### Artifact & lineage
|
||||
|
||||
| Edge | From → To | Time-bound? | Notes |
|
||||
|
||||
@@ -416,8 +416,15 @@ The plane model from `17-planes.md` is open-ended: any `kind` value is valid, an
|
||||
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'})
|
||||
// Adding a "Realm of Dreams" demiplane to a setting. The
|
||||
// Setting↔Plane link is a `setting_id` field on the Plane
|
||||
// (not a HAS_PLANE edge) — see docs/17-planes.md.
|
||||
MERGE (s:Setting {id: 'eberron'})
|
||||
SET s.name = 'Eberron', s.kind = 'campaign', s.schema_version = '1.2';
|
||||
|
||||
MERGE (p:Plane {id: 'eberron.realm_of_dreams'})
|
||||
SET p.name = 'The Realm of Dreams',
|
||||
p.setting_id = 'eberron',
|
||||
p.kind = 'demiplane',
|
||||
p.summary = 'A demiplane entered only through sleep or specific fey magic.',
|
||||
p.accessible = true,
|
||||
|
||||
@@ -582,18 +582,29 @@ 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;
|
||||
// Planes (note: Setting ↔ Plane membership is encoded as
|
||||
// a `setting_id` field on the Plane node, not as a
|
||||
// HAS_PLANE edge — see docs/17-planes.md for the rationale)
|
||||
MERGE (m:Plane {id: 'mardonari.material'})
|
||||
SET m.name = 'The Material Plane of Mardonari',
|
||||
m.setting_id = 'mardonari',
|
||||
m.kind = 'material',
|
||||
m.accessible = true;
|
||||
|
||||
MERGE (s)-[:HAS_PLANE]->(d:Plane {id: 'mardonari.voldramir'})
|
||||
SET d.name = 'Voldramir', d.kind = 'demiplane', d.accessible = true,
|
||||
MERGE (d:Plane {id: 'mardonari.voldramir'})
|
||||
SET d.name = 'Voldramir',
|
||||
d.setting_id = 'mardonari',
|
||||
d.kind = 'demiplane',
|
||||
d.accessible = true,
|
||||
d.summary = 'A small demiplane of forges and reed-beds. 50 miles across. Reachable only through a specific stone door in the Wheel & Kiln of Mardonar.';
|
||||
|
||||
MERGE (d)-[:LAYER_OF]->(m);
|
||||
|
||||
MERGE (s)-[:HAS_PLANE]->(a:Plane {id: 'mardonari.astral'})
|
||||
SET a.name = 'The Astral Plane of Mardonari', a.kind = 'transit', a.accessible = false;
|
||||
MERGE (a:Plane {id: 'mardonari.astral'})
|
||||
SET a.name = 'The Astral Plane of Mardonari',
|
||||
a.setting_id = 'mardonari',
|
||||
a.kind = 'transit',
|
||||
a.accessible = false;
|
||||
|
||||
MERGE (a)-[:ADJACENT_TO]->(m);
|
||||
```
|
||||
|
||||
@@ -8,9 +8,9 @@ This document is the new plane model. It supersedes the v1.1 "world_id string na
|
||||
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).
|
||||
- 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.
|
||||
|
||||
@@ -36,7 +36,7 @@ A campaign, game, or world scope. The top of the world hierarchy. A `Setting` is
|
||||
| `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).
|
||||
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`
|
||||
|
||||
@@ -73,52 +73,59 @@ A setting does not need every kind. A low-magic home-brew world might have only
|
||||
|
||||
## Edge types (plane model)
|
||||
|
||||
### `HAS_PLANE` (Setting → Plane)
|
||||
### Setting ↔ Plane membership (`Plane.setting_id` field)
|
||||
|
||||
Every `Plane` belongs to exactly one `Setting`. This is the root of the plane tree.
|
||||
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
|
||||
(:Setting {id: 'eberron'})-[:HAS_PLANE]->(:Plane {id: 'eberron.material', kind: 'material'})
|
||||
(:Person {name: 'Roland Raventhorne'})-[:EXISTS_IN]->(:Setting {id: 'mardonari'})
|
||||
(:Person {name: 'The Wanderer'})-[:EXISTS_IN]->(:Setting {id: 'the_wild_dream'})
|
||||
```
|
||||
|
||||
### `EXISTS_IN` (any entity → Plane) — timeless type-assertion
|
||||
`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.
|
||||
|
||||
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?"
|
||||
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.
|
||||
|
||||
```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'})
|
||||
```
|
||||
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.
|
||||
|
||||
`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.
|
||||
### `LOCATED_IN_PLANE` (any entity → Plane) — time-bounded
|
||||
|
||||
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
|
||||
```
|
||||
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
|
||||
|
||||
@@ -188,15 +195,27 @@ The `{setting}` prefix is a convention, not a constraint. A setting with one pla
|
||||
|
||||
### "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
|
||||
MATCH (p:Person {name: 'Asmodeus'})-[:EXISTS_IN]->(plane:Plane)
|
||||
// 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)
|
||||
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
|
||||
```
|
||||
@@ -204,7 +223,7 @@ 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 (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
|
||||
@@ -231,7 +250,7 @@ The v1.1 design used `world_id: string` on every node as a flat namespace. The v
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -245,15 +264,33 @@ The v1.1 design used `world_id: string` on every node as a flat namespace. The v
|
||||
|
||||
## Open questions and trade-offs
|
||||
|
||||
### `EXISTS_IN` is many-to-many
|
||||
### `EXISTS_IN` (entity → Setting) is many-to-many
|
||||
|
||||
Default choice (Q-clarify default): a node can have `EXISTS_IN` edges to many planes. The trade-off:
|
||||
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 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.
|
||||
- **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.
|
||||
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
|
||||
|
||||
|
||||
208
docs/adr/0013-plane-model-migration.md
Normal file
208
docs/adr/0013-plane-model-migration.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Slice 6 — Plane model: First-class Setting + Plane graph nodes
|
||||
|
||||
**Status:** accepted (slice 6 shipped 2026-06-19; 712→756 tests, +44).
|
||||
|
||||
**Drives:** [`docs/17-planes.md`](../17-planes.md),
|
||||
[`docs/plan/06-slice-planes.md`](../plan/06-slice-planes.md),
|
||||
[`docs/plan/exec/06-planes.md`](../plan/exec/06-planes.md).
|
||||
Supersedes: the v1.1 `world_id` string namespace
|
||||
(ADR 0008 substrate; slice 5T polymorphic layer 2 still applies).
|
||||
|
||||
## Context
|
||||
|
||||
The v1.1 graph model used a `world_id: string` field on every
|
||||
entity as a flat namespace. This was sufficient for "what world
|
||||
is X in?" but inadequate for the D&D-style cosmology the engine
|
||||
is being built for: a setting has many planes, planes have
|
||||
relations (Shadowfell reflects Material, Voldramir is a
|
||||
demiplane within Material), and an entity can exist in
|
||||
multiple settings or be `LOCATED_IN` a plane at specific times.
|
||||
|
||||
Slice 6 promotes `Setting` and `Plane` to first-class graph
|
||||
nodes, with the four plane-relation edge types
|
||||
(`REFLECTS`, `LAYER_OF`, `ADJACENT_TO`, `ACCESSIBLE_VIA`),
|
||||
a timeless `EXISTS_IN` edge (entity → Setting) for setting
|
||||
membership, and a time-bounded `LOCATED_IN_PLANE` edge
|
||||
(entity → Plane) for "where was X at time T".
|
||||
|
||||
## Decision
|
||||
|
||||
Slice 6 ships in 6 sub-slices (6.1 through 6.6) plus a docs
|
||||
cleanup (6.7). The locked decisions:
|
||||
|
||||
1. **Setting + Plane are first-class graph nodes** (Layer 1
|
||||
core, alongside Person, Faction, etc.). Added to
|
||||
`NODE_LABELS` in `lore_engine_poc/ontology.py`. Stored as
|
||||
dataclasses (`Setting`, `Plane`) in
|
||||
`lore_engine_poc/setting.py`.
|
||||
|
||||
2. **Setting ↔ Plane membership is a `setting_id` field on
|
||||
the Plane node, not a `HAS_PLANE` edge.** The relation is
|
||||
single-valued (every Plane has exactly one Setting parent);
|
||||
a field is simpler than an edge and gives the same O(1)
|
||||
reverse-lookup (`planes_in_setting(setting_id)` via
|
||||
`planes_by_setting` index). This is a deliberate deviation
|
||||
from the pure-Cypher pattern shown in
|
||||
`docs/17-planes.md` (which shows a `HAS_PLANE` edge) —
|
||||
the conceptual model is unchanged; only the storage
|
||||
form differs.
|
||||
|
||||
3. **EXISTS_IN (entity → Setting) is timeless.** It is the
|
||||
setting filter the slice 6.5 read tools consume. The
|
||||
time-bounded planar location is a separate edge,
|
||||
`LOCATED_IN_PLANE` (entity → Plane), with `valid_from`
|
||||
and `valid_until`. Both edges are first-class write
|
||||
chokepoints on the `GraphBackend` Protocol.
|
||||
|
||||
4. **GraphBackend Protocol extended with 8 new methods**
|
||||
(`add_setting`, `find_setting`, `add_plane`, `find_plane`,
|
||||
`planes_in_setting`, `add_exists_in`, `entity_planes`,
|
||||
`setting_entities`). All 8 are implemented in
|
||||
`InMemoryGraph`; Neo4jGraph ships `NotImplementedError`
|
||||
stubs (slice 5's substrate, parity is a follow-up). The
|
||||
reverse-lookup indices (`planes_by_setting`,
|
||||
`entities_by_setting`, `settings_by_entity`) keep all
|
||||
reverse queries O(1).
|
||||
|
||||
5. **Migration is additive and idempotent.** Two migration
|
||||
helpers in `lore_engine_poc/migration.py`:
|
||||
- `migrate_setting_id_to_exists_in(graph, setting_id,
|
||||
entity_ids=...)` — materialises Setting + default
|
||||
Material Plane + EXISTS_IN facts (slice 6.4).
|
||||
- `scan_codex_for_planes` + `apply_plane_migration` —
|
||||
reads the codex frontmatter, promotes entries with
|
||||
`plane: true` / `tags contains plane` / `type: plane`
|
||||
to `:Plane` nodes, and creates `LAYER_OF` edges for
|
||||
co-referenced Planes (slice 6.6).
|
||||
|
||||
6. **The 6 read tools gain a `setting=` filter.** Slice 6.5
|
||||
adds `setting: Optional[str] = None` (keyword-only) to
|
||||
`lookup`, `entity_context`, `was_true_at`, `true_during`,
|
||||
`entities_present`, `events_during`. Filter resolves via
|
||||
`setting_entities(setting_id)` — O(1) reverse lookup.
|
||||
The cross-setting case ("Roland Raventhorne in mardonari
|
||||
ENCOUNTERED The Wanderer in the_wild_dream") is pinned by
|
||||
`test_6_5_setting_filter_on_was_true_at` and surfaces
|
||||
`was_true: False` with `note: "unknown entity: <name>"`
|
||||
when the object isn't in the named setting.
|
||||
|
||||
7. **The 4 plane-relation edge types are typed, non-reified,
|
||||
timeless.** `REFLECTS`, `LAYER_OF`, `ADJACENT_TO`,
|
||||
`ACCESSIBLE_VIA` are added to `EDGE_TYPES` in
|
||||
`ontology.py` and shipped through the GraphBackend
|
||||
Protocol's existing `add(Edge)` chokepoint — no new
|
||||
write methods needed. The slice 6.3 characterisation
|
||||
tests pin the direction conventions: LAYER_OF points
|
||||
from the layer to the parent; REFLECTS from the
|
||||
reflection to the material; ADJACENT_TO is symmetric;
|
||||
ACCESSIBLE_VIA from the plane to the spell/item/location.
|
||||
|
||||
## What was rejected (and why)
|
||||
|
||||
- **`HAS_PLANE` edge between Setting and Plane.** Rejected in
|
||||
favour of a `setting_id` field on the Plane (decision 2).
|
||||
The edge would be redundant with the field; a setting→plane
|
||||
edge cannot point at multiple planes per setting (it can
|
||||
only point at one), so the simpler storage form is the
|
||||
right call. The Cypher example in `17-planes.md` is
|
||||
preserved as the *intent* — the engine just stores it
|
||||
differently.
|
||||
- **EXISTS_IN pointing at Plane, not Setting.** Rejected
|
||||
(decision 3). The timeless membership question is "is X in
|
||||
setting S?" not "is X in plane P?"; an entity can be in
|
||||
many planes (god, outsider) but a more useful question is
|
||||
"what setting does X belong to?" The setting→plane chain
|
||||
(entity EXISTS_IN setting → planes_in_setting) gives the
|
||||
same answer with the right shape.
|
||||
- **Time-bounded `EXISTS_IN`.** Rejected (decision 3). The
|
||||
timeless-vs-bounded distinction is what motivates
|
||||
`EXISTS_IN` (timeless) and `LOCATED_IN_PLANE` (bounded) as
|
||||
separate edges. v1.1 conflating the two was the bug; v1.2
|
||||
separates them.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- Multi-setting queries are O(1): the slice 6.5 setting
|
||||
filter is a reverse-lookup on `entities_by_setting`.
|
||||
- The codex can model D&D-style cosmology natively —
|
||||
Voldramir is a `Plane` node with `kind: 'demiplane'`, and
|
||||
the body-text `[[Underdark]]` wikilink in Voldramir.md
|
||||
becomes a typed `LAYER_OF` edge.
|
||||
- The `Plane.kind` enum (material/reflection/transit/
|
||||
ethereal/outer/inner/demiplane/transcendent) is a strong
|
||||
discriminator the LLM can reason over — "what planes are
|
||||
demiplanes in mardonari?" is one Cypher.
|
||||
- Idempotent migration: re-running `05_migrate_planes.py` on
|
||||
the same codex produces the same graph state. The slice
|
||||
6.5 settings filter is also idempotent (an entity with
|
||||
`EXISTS_IN S` already → skip).
|
||||
|
||||
**Negative / deferred:**
|
||||
|
||||
- The Neo4jGraph backend still has `NotImplementedError`
|
||||
stubs for the 8 new methods. Slice 5's parity was for the
|
||||
core read/write path; the slice 6.2/6.5/6.6 work is
|
||||
in-memory only. Bringing Neo4j to parity is a follow-up
|
||||
sub-slice (6.8?) that mirrors the InMemoryGraph
|
||||
implementations.
|
||||
- The slice 6.4 backfill defaults to a single
|
||||
`Material Plane` per Setting. A setting with multiple
|
||||
starting planes (Material + Feywild) requires the caller
|
||||
to call `migrate_setting_id_to_exists_in` once for each
|
||||
default plane; this is fine for the POC but a future
|
||||
slice may add a `default_planes: list[str]` parameter.
|
||||
- The migration's discriminator accepts three frontmatter
|
||||
shapes (`plane: true`, `tags contains plane`, `type: plane`)
|
||||
— this is *more* permissive than the v1.2 design's
|
||||
preferred form (`plane: true`). The script's
|
||||
`_infer_plane_kind` is conservative (default demiplane
|
||||
when no explicit kind); the actual kind for Voldramir in
|
||||
the Mardonari codex is demiplane, which is correct, but
|
||||
the Underdark is also inferred as demiplane from the
|
||||
bare `tags: [region, plane]` — a future codex pass may
|
||||
disambiguate the Underdark as a different kind.
|
||||
|
||||
## Sub-slice index
|
||||
|
||||
- **6.1** — `lore_engine_poc/setting.py` (Setting + Plane
|
||||
dataclasses); ontology additions. 6 tests.
|
||||
`Setting(id, kind, current_era, schema_version, created_at)`,
|
||||
`Plane(id, setting_id, name, kind)`.
|
||||
- **6.2** — `GraphBackend` Protocol: 8 new methods +
|
||||
InMemoryGraph storage (3 new reverse-lookup indices) +
|
||||
Neo4jGraph `NotImplementedError` stubs. 8 tests.
|
||||
- **6.3** — `EDGE_TYPES` adds `REFLECTS`, `LAYER_OF`,
|
||||
`ADJACENT_TO`, `ACCESSIBLE_VIA`; 6 characterisation tests
|
||||
pin the direction conventions.
|
||||
- **6.4** — `migrate_setting_id_to_exists_in()` migration
|
||||
helper. `BackfillSummary` dataclass. 7 tests.
|
||||
- **6.5** — `setting=` keyword-only arg on 6 read tools;
|
||||
`mcp_tools.py` schema exposure. 8 tests; the cross-setting
|
||||
test (`Roland ENCOUNTERED The Wanderer` → `was_true: False`
|
||||
under `setting="mardonari"`) is the regression net.
|
||||
- **6.6** — `scan_codex_for_planes` + `apply_plane_migration`
|
||||
library functions; `scripts/05_migrate_planes.py` CLI with
|
||||
`--dry-run`. 9 tests.
|
||||
- **6.7** — docs cleanup: 01-ontology.md, 11-extensibility.md,
|
||||
14-examples.md, 17-planes.md updated to reflect the
|
||||
`setting_id` field encoding (not the `HAS_PLANE` edge
|
||||
shown in the design's Cypher example).
|
||||
|
||||
**Test count:** 712 → 756 (+44). No regressions.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`docs/17-planes.md`](../17-planes.md) — the design
|
||||
- [`docs/01-ontology.md`](../01-ontology.md) — ontology table
|
||||
- [`docs/plan/exec/06-planes.md`](../plan/exec/06-planes.md) — the exec roadmap
|
||||
- ADR 0004 — region ≠ plane (rationale for the discriminator)
|
||||
- ADR 0008 — Neo4j substrate (slice 5; parity is a follow-up)
|
||||
- ADR 0009 — reified `:Relation` edges (the time-bounded
|
||||
`LOCATED_IN_PLANE` uses the same `valid_from`/`valid_until`
|
||||
pattern)
|
||||
- ADR 0011 — `GraphBackend` Protocol (the 8 new methods sit
|
||||
alongside the existing `add`/`find`/etc. surface)
|
||||
- ADR 0012 — slice 5T (precedent for adding to `NODE_LABELS`;
|
||||
the same "Layer 2 / Layer 1" distinction applies)
|
||||
@@ -33,7 +33,7 @@ and the polymorphic Layer 2 (`:DomainEntity`, `:Relation`,
|
||||
|
||||
| # | Decision | Source | Implication |
|
||||
|---|---|---|---|
|
||||
| D1 | `EXISTS_IN` is a reified `:Relation` node, not a native edge | ADR 0009 | Time-bounded (planar travel) — the in-memory `Edge` from slice 0 stays the substrate; the new `EXISTS_IN` relation carries `valid_from`/`valid_until`. |
|
||||
| D1 | `EXISTS_IN` is a **typed, timeless** edge in `EDGE_TYPES`; **time-bounded** planar membership is carried by a *separate* reified `:Relation` (slice 6.5) | `docs/17-planes.md`; ADR 0009 | Per `docs/17-planes.md`, `EXISTS_IN` is the timeless type-assertion (an entity belongs to a Setting). Time-bounded planar membership (entity *was* in plane X *until* year N) is a reified `:Relation` with `valid_from`/`valid_until` — this is the slice 6.5 sub-slice, not a re-write of `EXISTS_IN`. |
|
||||
| D2 | `:Setting` and `:Plane` are top-level labels (Layer 1 core) | docs/01-ontology.md, ADR 0011 | They're added to `NODE_LABELS` in `lore_engine_poc/ontology.py`, same as `:DomainEntity` was in 5T.1. |
|
||||
| D3 | Default plane for an entity with no `plane:` is the Material Plane of the default Setting | AC 6.10 | Migration must auto-create a Material Plane when none is declared. |
|
||||
| D4 | Region vs Plane split via frontmatter | AC 6.8 | Entries with `plane: true` OR under `Campaign Codex - Planes/` are `:Plane`; everything else stays `:Region`. |
|
||||
|
||||
Reference in New Issue
Block a user