Story Entry Resolution¶
Document Version: 1.1
Status: CURRENT CONTRACT — compile-time defaults and world-level init-time overrides are implemented on the current tangl.story / tangl.service runtime surface
Relevant layers: tangl.story.fabula, loaders, tangl.service, tangl.vm.runtime
Problem Statement¶
When a story graph is initialized, the starting cursor position is not always a static property of the script. In anthology or carry-forward formats, the correct entry passage may depend on a user’s accumulated state across prior stories.
The system needs a clean way to express this without:
polluting
Ledgerwith domain-specific routing logicrequiring
RuntimeControllerto know anthology conventionsre-running the compiler or materializer for each user
Resolution Priority Chain¶
Entry resolution is a two-stage process: compile-time default, then init-time override.
Stage 1 — Compile-time default (StoryCompiler)¶
Resolved once when the world is loaded and stored in
StoryTemplateBundle.entry_template_ids. Priority order:
Explicit
metadata.start_atlabel or dotted path in the scriptFirst template carrying an
is_startannotation, either a locals field{"start_at": True}or the tag{"start_at"}First leaf child of the first top-level group as a pure convention fallback
This phase is deterministic and context-free.
Stage 2 — Init-time override (World.create_story)¶
Called once per story initialization, after materialization and before
StoryInitResult is returned. The world may inspect a caller-provided
namespace and substitute a different initial_cursor_id on the runtime graph.
The override returns a node uid or None. None means “accept the compiled
default.”
Namespace Contract¶
World.create_story accepts an optional
namespace argument:
def create_story(
self,
story_label: str,
*,
init_mode: InitMode = InitMode.EAGER,
namespace: dict | None = None,
) -> StoryInitResult:
The caller passes {"user": User} at minimum. Resolver logic may then inspect
domain-specific state such as carried flags, stats, or achievements without the
engine imposing a schema on user locals.
The canonical service caller is ServiceManager.create_story(...), which now
forwards a caller-provided namespace and injects the resolved user object for
user-bound story creation.
World Implementation Sketch¶
def create_story(
self,
story_label: str,
*,
init_mode: InitMode = InitMode.EAGER,
namespace: dict | None = None,
) -> StoryInitResult:
materializer = StoryMaterializer()
result = materializer.create_story(
bundle=self.bundle,
story_label=story_label,
init_mode=init_mode,
world=self,
)
if namespace is not None:
override_uid = self._resolve_entry_override(result.graph, namespace)
if override_uid is not None:
result.graph.initial_cursor_id = override_uid
return result
def _resolve_entry_override(
self,
graph: StoryGraph,
namespace: dict,
) -> UUID | None:
return None
Domain Registration Pattern¶
Anthology logic should live entirely in the domain package, not in the engine. One plausible shape is a world subclass or registry-backed override hook:
class AnthologyWorld(World):
_ENTRY_RESOLVERS: dict[str, Callable] = {}
@classmethod
def register_entry_resolver(cls, story_key: str):
def decorator(fn):
cls._ENTRY_RESOLVERS[story_key] = fn
return fn
return decorator
def _resolve_entry_override(self, graph, namespace):
story_key = self.label # or any equivalent stable story identifier
resolver = self._ENTRY_RESOLVERS.get(story_key)
if resolver is not None:
return resolver(graph, namespace)
return None
This keeps story-specific routing rules opaque to the engine.
Ledger Role¶
Ledger is intentionally passive with respect to entry resolution. It receives
initial_cursor_id from the graph and treats it as authoritative:
ledger = Ledger.from_graph(
graph=story_graph,
entry_id=story_graph.initial_cursor_id,
)
This preserves the separation of concerns: graph initialization decides entry; ledger owns traversal state after initialization.
Design Constraints¶
No recompilation per user: the override mutates only the per-story runtime graph returned from
create_story.No ledger mutation: the ledger is initialized after override has already been applied.
Domain logic stays in the domain package:
Worldand service controllers expose only the seam.Namespace shape stays open:
{"user": User}is the minimum useful contract.Compile-time default is always present: a missing resolver is not an error.
Current Implementation Status¶
The current tangl.story runtime supports both parts of the entry-resolution
chain:
compile-time entry resolution through
StoryTemplateBundle.entry_template_idsinit-time override through
World.create_story(..., namespace=...)plus_resolve_entry_override(...)
The canonical service path also participates:
ServiceManager.create_story(...)forwards a namespace and injects the resolveduserobject for user-bound story initialization
The remaining design freedom is intentionally domain-level: worlds decide what
extra namespace keys they consume beyond the baseline user object.