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.
12 KiB
02 — Time Model
If the ontology is the floor of the engine, time is the load-bearing wall. Most of the historical-accuracy failures we want to prevent are time failures: a character appears in an event before they were born, a house rules a kingdom after its fall, a fact is stated as "currently true" when it was true only in a specific century.
This document is the contract for how time is represented, indexed, and queried.
Goals
- Every time-bound edge is queryable as "was X true at time T?" in a single round trip.
- A single canonical time format that the LLM, the extraction workers, and the Cypher queries all use. No
"340 TA","340 Third Age","Year 340","340 of the Third","3.340"all meaning the same thing. - Eras are hierarchical. A fact that happens "in the Third Age" is also "in the Age of Iron" if the Age of Iron is a sub-era. The engine answers the more specific question correctly.
- Open-ended validity is first-class. "Aldric ruled from 340 TA until his death" is
valid_until: null, notvalid_until: "present". - Time references are a node, not a string.
Era,Calendar, and the in-fictionDateare typed. The LLM reasons over them.
The canonical time format
All time references in the engine, on every edge, in every property, in every tool call, are encoded as:
{era}.{unit}
Where:
erais the slug of anEranode (e.g.3rd_age,age_of_iron,long_winter).unitis the most specific time unit the source supports. Per Kay's answer (Q1), the engine supports year-level precision by default, with optional month/day/event ID when the source supports it. Examples:year_340,month_3,day_17,event_42(referencing aDatenode by ID).
A bare {era} is valid when no finer granularity is known — it means "at some point during this era."
Examples:
| Source phrase | Canonical form |
|---|---|
| "in 340 TA" | 3rd_age.year_340 |
| "during the Age of Iron" | 3rd_age.age_of_iron |
| "at the Battle of Black Spire" | 3rd_age.event_battle-of-black-spire (where event_battle-of-black-spire is a Date node) |
| "around the time of the Long Winter" | 3rd_age.long_winter (a sub-era) |
| "in the present" | current (a reserved token — see below) |
| "unknown" | null |
The format is deliberately non-human-readable. The LLM sees a stable identifier; the engine looks up the human name when returning results. This is the same pattern as content-addressable storage: opaque to humans, deterministic for the system.
Reserved tokens
current— the present moment of the in-fiction world. The engine resolves this against a single configurable node (:Nowof typeDate) that the storyteller updates as the campaign progresses.null— explicitly unknown. Not the same as "current"; means "we have no data."epoch— the moment of calendar epoch. Most worlds have one; this is the absolute zero.
Era hierarchy
Eras form a tree. A Date node can be nested inside an Era, and an Era can be nested inside a parent Era:
(third_age:Era {name: "Third Age", slug: "3rd_age"})
-[:CONTAINS]->
(age_of_iron:Era {name: "Age of Iron", slug: "3rd_age.age_of_iron"})
-[:CONTAINS]->
(long_winter:Era {name: "The Long Winter", slug: "3rd_age.age_of_iron.long_winter"})
A query like "what happened during the Long Winter?" is:
MATCH (e:Era {slug: "3rd_age.age_of_iron.long_winter"})<-[:OCCURRED_DURING]-(event:Event)
RETURN event
A query like "what happened during the Third Age?" must traverse the hierarchy:
MATCH (root:Era {slug: "3rd_age"})
OPTIONAL MATCH path = (root)-[:CONTAINS*0..5]->(sub:Era)
WITH collect(root) + collect(sub) AS eras
MATCH (event:Event)-[:OCCURRED_DURING]->(e:Era)
WHERE e IN eras
RETURN DISTINCT event
The MCP tool query_era exposes both. See 05-mcp-tools.md.
The valid_from / valid_until pattern
Every time-bound edge carries both:
(:Person {name: "Aldric"})-[:RULED {
valid_from: "3rd_age.year_340",
valid_until: "3rd_age.year_352"
}]->(:Location {name: "Valdorn"})
The engine treats null on either side as "we don't know / open-ended." So:
valid_from: null, valid_until: null→ we don't know when this was true.valid_from: "3rd_age.year_340", valid_until: null→ true from 340 TA onward, still true (or unknown when it ended).valid_from: null, valid_until: "3rd_age.year_352"→ true at some point, but no longer after 352 TA.valid_from: "3rd_age.year_340", valid_until: "3rd_age.year_352"→ exact window known.
The Cypher for "was this edge true at time T" is a single WHERE clause that calls a time_in_window function (implemented as a user-defined function in Neo4j — see 08-architecture.md#udfs).
The time_in_window UDF (the heart of the engine)
// Returns true if `t` falls within [valid_from, valid_until] inclusive
// of open-ended bounds. `t` is the canonical time string being queried.
RETURN time_in_window(
'3rd_age.year_345',
'3rd_age.year_340',
'3rd_age.year_352'
) // → true
RETURN time_in_window(
'3rd_age.year_360',
'3rd_age.year_340',
'3rd_age.year_352'
) // → false
This function handles:
nullbounds (open-ended)- Era-tree membership:
3rd_age.age_of_ironfalls inside3rd_age currentresolution against the:Nowconfig node- Comparison against
Datenodes (events, named days)
It's the single primitive the rest of the engine is built on. If this is wrong, every time-aware query is wrong. See 10-critique.md for the known edge cases.
Date nodes — when an event has a specific day
For events that have a specific in-fiction date (a battle, a coronation, a death), we model the date as a node:
(:Date {
id: "3rd_age.event_battle-of-black-spire",
slug: "3rd_age.event_battle-of-black-spire",
in_fiction_label: "17th Hearthmoon, 340 TA",
era: "3rd_age",
calendar: "imperial_reckoning",
year: 340,
month: 3,
day: 17,
canonical_event: "Battle of Black Spire"
})
-[:PART_OF]->(:Era {slug: "3rd_age"})
-[:USES]->(:Calendar {name: "Imperial Reckoning"})
The slug format lets edges reference dates by ID, which means:
- The Battle's
OCCURRED_ATedge can use the date as its temporal marker. - A character can be
BORN_ONa specific date. - A lineage event (
MARRIED_ON) can have the same precision.
Not every event needs a Date node. Most historical facts have era-level or year-level precision. The Date node is for the moments that matter.
Lifespan — a Person's existence window
A Person node carries a lifespan as either a property range or as EXISTED_DURING connections:
(:Person {name: "Aldric Raventhorne"})
-[:EXISTED_DURING {from: "3rd_age.year_300", until: "3rd_age.year_360"}]->
(:Era {slug: "3rd_age"})
This makes anachronism detection a single Cypher query: "Was Aldric at the Battle of Black Spire?" → check if battle.date is within Aldric.lifespan. If not, flag it. See 04-consistency.md#anachronism-detection.
The same pattern applies to Faction (founding/dissolving), Location (existence), and Item (creation/destruction).
Time-aware queries: the 5 primitives
The MCP tool layer is built on five time-aware query primitives. Every other query is a composition of these.
1. was_true_at(relation_type, subject, object, at_time)
Returns the matching edge if it was true at at_time, else not_found.
2. true_during(relation_type, subject, object, era)
Returns all time-bounded edges of that type between subject and object, with the intervals in which each was true.
3. state_at(entity, at_time)
Returns a snapshot of the entity's relations at at_time: who they were allied with, where they were located, what they held, who ruled them. The single most important query — answers "who is this person at time T?"
4. entities_present(location, at_time)
Returns all entities (Person, Faction, Creature) recorded as located at the location during the time window.
5. timeline(entity, relation_type, from, to)
Returns a chronologically ordered list of edges of that type involving the entity, optionally filtered to a time window.
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:
(: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.
- We do not do fuzzy time matching. "Around 340 TA" must be resolved at ingest time to a specific canonical string. The engine does not guess.
- We do not store wall-clock time in the ontology. The wall-clock
created_atis metadata only, used for staleness and source recency, never for in-fiction reasoning.
Known limitations (and why we accept them)
A fact that was true for centuries cannot be precisely queried by century — only at the era granularity we have. This is fine: most lore is at era granularity, and over-precision usually means fabrication. Year-level precision is supported for the cases where the source supports it (coronations, battles, deaths, births), and the engine prefers the most-specific-time-the-source-supports rather than guessing.
- Two sources giving the same era but different years produce a contradiction, not a synthesis. The engine flags it; it does not pick a winner. See
04-consistency.md. - "Before the world began" or "after the end of time" are not representable. A
nullvalid_fromwith no parentErais the closest, and we mark those asmythic: trueto be clear they are cosmological claims, not historical ones.
The time model is the part of the engine that is most likely to need a v2. The 14 docs in this design describe v1. If you find yourself wanting year-level precision and we only have era-level, that's a source problem (write a better source document), not a time-model problem.