ServicesNarrative Engine

Scopes and Promotion

Every actor sits at one of three scopes, and the whole service is built around moving state between them:

  • session - a single player's live scratch space. Cheap, private to that run of play, and deleted when the session ends or lapses.
  • story - shared across the sessions of one narrative arc. Survives the sessions beneath it.
  • universe - canon. The state everyone sees.

Work happens at session scope; anything worth keeping is promoted up before the session goes away. That single sentence is the service's design.

An actor's scope is fixed at creation by the flags you pass:

snug narrative instantiate-actor -u <universe-id> -t <template-id> -n "Scout" \
  --session-id <session-id>            # session scope
snug narrative instantiate-actor -u <universe-id> -t <template-id> -n "Scout" \
  --story-id <story-id>                # story scope
snug narrative instantiate-actor -u <universe-id> -t <template-id> -n "Scout"
                                       # universe scope
{ "actor_id": "jwKNbvdSthnD", "scope": "session",
  "session_id": "ArTvPMEjREvc", "story_id": null, "name": "docs4-Scout" }

Filter by scope with list-actors -s session or query-actors -s session.

Sessions are destructive containers

A session-scoped actor is owned by its session in the strongest sense. Both ways a session can end take its actors with it:

Ending a session deletes its actors. end-session is a hard delete of the session record; the session-scoped actors go with it, and nothing about the call warns you:

snug narrative end-session -u $U --session-id ArTvPMEjREvc
# {"message":"Session 'ArTvPMEjREvc' ended","success":true}

snug narrative get-actor -u $U -a jwKNbvdSthnD
# 404 actor_not_found

Letting a session expire does the same thing, on a delay. A background sweep runs every NARRATIVE_SESSION_CLEANUP_INTERVAL_SECONDS (30s by default) and deletes expired sessions along with their actors. Verified with a 5-second session: 50 seconds later both the session and the actor instantiated into it returned 404.

Between the TTL lapsing and the sweep landing, expiry is only advisory. The session still reads back, now with is_expired: true; it is filtered out of list-sessions --active-only; and you can still instantiate actors into it or revive it outright:

snug narrative update-session -u $U --session-id <id> --extend-ttl-seconds 600
# -> is_expired: false, expires_at pushed out

So a lapsed session is recoverable for about half a minute and unrecoverable after that. Do not treat is_expired: false as proof a session is safe - extend it, or promote what you need out of it.

Promotion

promote-actor copies an actor into a broader scope. It does not move it: the source actor stays exactly where it was, and a new actor id is returned.

snug narrative promote-actor -u <universe-id> -a <actor-id> \
  --from-scope session --to-scope story --to-story-id <story-id> \
  --strategy merge
{
  "success": true,
  "source_actor_id": "jwKNbvdSthnD",
  "target_actor_id": "UPwKqHxKJBPY",
  "from_scope": "session",
  "to_scope": "story",
  "conflicts_resolved": 0,
  "fields_promoted": [],
  "actor": {
    "actor_id": "UPwKqHxKJBPY",
    "scope": "story",
    "story_id": "HtvpgDTNNsrt",
    "source_actor_id": "jwKNbvdSthnD",
    "source_version": 1,
    "name": "docs4-Scout"
  }
}

The promoted copy records where it came from in source_actor_id and source_version, which is how you audit what a session contributed to canon.

This is what makes session state survivable. The promoted copy above was still present, at story scope, after its origin session was ended and the session-scoped original was deleted.

Strategies are merge (default), replace, and selective with --fields. A selective promotion echoes back exactly what it carried up:

snug narrative promote-actor -u $U -a $A --from-scope session --to-scope universe \
  --strategy selective --fields "name,tags"
{
  "success": true,
  "source_actor_id": "JgPFNMpweghL",
  "target_actor_id": "dpHAPPnMNrpm",
  "to_scope": "universe",
  "fields_promoted": ["name", "tags"],
  "conflicts_resolved": 0
}

Promotion only ever goes up the hierarchy, and story targets need an explicit --to-story-id. Both mistakes return 400 invalid_promotion:

{ "status": 400, "error": "invalid_promotion",
  "msg": "Invalid promotion: Can only promote to a broader scope (session -> story -> universe)" }
{ "status": 400, "error": "invalid_promotion",
  "msg": "Invalid promotion: to_story_id is required when promoting to story scope" }

Replication

replicate-actor copies an actor into any scope, including sideways and downward - handy for seeding a session with a copy of a canon NPC, or forking an actor for a what-if branch.

snug narrative replicate-actor -u <universe-id> -a <actor-id> \
  --new-actor-id scout-copy --to-scope universe
snug narrative replicate-actor -u <universe-id> -a <actor-id> \
  --new-actor-id scout-in-play --to-scope session --to-session-id <session-id>

Unlike every other create in this service, you choose the id, so ids can collide. A second replicate with the same --new-actor-id fails - as a generic invalid_request 400, not a dedicated 409 conflict:

{ "status": 400, "error": "invalid_request",
  "msg": "Invalid request: Entity already exists: docs4-scout-copy" }

A working shape

The lifecycle that ties this together:

# 1. Canon lives at universe scope.
ACTOR=$(snug --output json narrative instantiate-actor -u $U -t $TEMPLATE \
  -n "Aria" | jq -r .actor_id)

# 2. A player starts a run; copy canon down into their session.
SESSION=$(snug --output json narrative create-session -u $U -p player-42 \
  -s $STORY --ttl-seconds 3600 | jq -r .session_id)
snug narrative replicate-actor -u $U -a $ACTOR \
  --new-actor-id "aria-$SESSION" --to-scope session --to-session-id $SESSION

# 3. Play mutates the session copy only. Canon is untouched.
snug narrative set-event -u $U -a "aria-$SESSION" -k chapter_1_done \
  --value-json true

# 4. Before the session ends, promote what should become canon.
snug narrative promote-actor -u $U -a "aria-$SESSION" \
  --from-scope session --to-scope story --to-story-id $STORY

# 5. Ending the session discards the scratch copy.
snug narrative end-session -u $U --session-id $SESSION

Step 4 is the one you cannot skip. There is no undo after step 5.

Reference

On this page