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 Ledger with domain-specific routing logic

  • requiring RuntimeController to know anthology conventions

  • re-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:

  1. Explicit metadata.start_at label or dotted path in the script

  2. First template carrying an is_start annotation, either a locals field {"start_at": True} or the tag {"start_at"}

  3. 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: World and 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_ids

  • init-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 resolved user object 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.