ServicesNarrative Engine

Control Grants

A control grant answers "who is allowed to move this actor right now" while a session is in play. Two things about it are counterintuitive enough to lead with:

  • A grant is a lock, not a permission. With no grant on an actor, anyone in the tenant may mutate it. Granting control removes access from everyone else; it does not add access for the grantee.
  • A grant is destructive when it expires. By default, an expiring grant on a session-scoped actor deletes that actor.

Granting and releasing

snug narrative grant-control -u <universe-id> -a <actor-id> \
  -p <player-id> --session-id <session-id> \
  --mode exclusive --duration-seconds 600
snug narrative get-control -u <universe-id> -a <actor-id>
snug narrative release-control -u <universe-id> -a <actor-id>

--session-id is required even when the actor is not session-scoped. Modes are exclusive (default) and shared; the duration defaults to 3600 seconds.

{
  "control_id": "ZazGdynyMPnq",
  "actor_id": "HYqUBqMpEJrA",
  "player_id": "docs4-other",
  "session_id": "ArTvPMEjREvc",
  "control_mode": "exclusive",
  "source_scope": "universe",
  "allowed_mutations": {},
  "on_release": { "action": "discard" },
  "expires_at": "2026-08-29T12:58:48.553071Z",
  "is_expired": false
}

One grant at a time. Asking for control of an actor someone else holds exclusively is a conflict:

{
  "status": 409,
  "msg": "Control already granted: actor 'HYqUBqMpEJrA' is exclusively controlled by player 'docs4-other'",
  "error": "control_already_granted"
}

After a release, get-control reports 404 control_not_found rather than an empty result.

player_id must be the caller's token subject

The server compares the grant's player_id against the JWT principal of whoever is mutating. It is not a free-form game-side label: if player_id is anything other than a real token subject, the grant locks out everyone including the player it names.

Verified both directions. A grant to docs4-other blocks the docs-wave token:

snug narrative grant-control -u $U -a $A -p docs4-other --session-id $S
snug narrative update-actor -u $U -a $A -n "Renamed"
{
  "status": 403,
  "msg": "Unauthorized mutation: actor is exclusively controlled by player 'docs4-other'",
  "error": "unauthorized_mutation"
}

and a grant to docs-wave lets that token through while blocking a second token minted for docs4-other in the same tenant.

shared mode skips this check entirely - any caller may mutate. Use it when you want the per-field narrowing below without excluding anyone.

Narrowing which fields may change

allowed_mutations turns a grant into a per-field allowlist. An empty map (the default) permits every field; a non-empty map permits only the paths it names, or everything under a * key. There is no CLI flag for it, so it is set over HTTP:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"player_id":"docs-wave","session_id":"'$S'","control_mode":"exclusive",
       "allowed_mutations":{"name":true},"duration_seconds":600}' \
  http://localhost:4000/api/v1/narrative/universes/$U/actors/$A/control

With that grant active, renaming the actor succeeds and re-tagging it does not:

{
  "status": 403,
  "msg": "Unauthorized mutation: mutation of 'tags' is not permitted by the active control grant",
  "error": "unauthorized_mutation"
}

The paths an actor update can touch are name, properties, tags, metadata and publish_state. A single-attribute write is checked as attributes.{attribute_template_id}, so a grant can hand a player one stat and nothing else:

{ "allowed_mutations": { "attributes.eawYDFDWVqpQ": true } }

Expiry, and the actor it takes with it

Grants expire on their own. A background task sweeps every NARRATIVE_CONTROL_EXPIRATION_INTERVAL_SECONDS (10s by default) and runs the grant's on_release action before deleting the grant record.

discard is the default, and on a session-scoped actor it means delete. Verified: an exclusive grant with duration_seconds: 5 on an actor instantiated into a live session. Twenty-five seconds later the grant was gone as expected - and so was the actor, while the session itself was still alive and unexpired:

snug narrative get-control -u $U -a $A   # 404 control_not_found
snug narrative get-actor   -u $U -a $A   # 404 actor_not_found
snug narrative get-session -u $U --session-id $S   # is_expired: false

The deletion is scoped to the grant's source_scope. A grant taken on a universe-scoped actor discards nothing: verified with the same 5-second expiry, the actor was still there afterwards, only the grant record went away.

So the rule is: never leave a default grant on a session-scoped actor you intend to keep. Either release it explicitly before it lapses, or set on_release to promote:

{ "on_release": { "action": "promote" } }

promote spares the actor and copies it up to story scope instead - it issues a session-to-story promotion using the actor's own story_id as the target. If the actor has no story_id (a session created without -s <story-id>), there is nothing to promote into; the promotion fails, the server logs a warning, and the API tells you nothing. The actor still survives, which is the important half, but no copy is made. Verified: a session-scoped actor with story_id: null and on_release: promote was intact after expiry with no new story- or universe-scoped copy anywhere.

Bind sessions to a story if you plan to rely on promote-on-expiry.

Anyone can release anyone's grant

release-control takes no proof that you hold the grant. A second token in the tenant released an exclusive grant held by docs-wave and got a plain success back:

{ "message": "Control released for actor 'jGDDyhHZuNeY'", "success": true }

Combined with narrative's tenant-wide visibility, control grants coordinate cooperating clients; they are not a security boundary against a hostile one. Note also that release-control --promote and the expiry-time promote action are different paths - the flag is on the manual release, the on_release object governs the sweep.

Reference

On this page