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/controlWith 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: falseThe 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
- Narrative API - grant request and response schemas
- Scopes and promotion - the other way session actors disappear
- Actors and attributes - the fields
allowed_mutationsnames