LaserData Cloud
Laser SDKAdvanced

Governance

Managed roles, pre-action policy, session budgets, and durable approval records

This page is the reference for controlling who can act and which effects can proceed. For running agents, start with Agents.

Two permission layers guard different things. Native Apache Iggy permissions decide whether a credential can see streams, create topics, send records, and poll records. Managed roles decide whether the same server-stamped user can call managed features such as queries, key-value state, graph, forks, and session reads. On top of both, an ActionGovernor in your process can stop or change a single effect before it happens, and intent records let several agents approve an action before anyone applies it. Access is denied by default, and a matching deny wins.

Managed roles

A grant reads effect feature:action [on resource-pattern]. A role is a named set of grants, and a binding gives roles to an Iggy user ID.

import type { Role } from "@laserdata/laser-sdk"

const role: Role = {
  name: "support-reader",
  grants: [{
    effect: "allow",
    feature: "kv",
    action: "read",
    resource: { kind: "prefix", value: "stream:support/" }
  }]
}

await laser.defineRole(role)
await laser.bindRoles(7, [role.name])
use laser_sdk::prelude::PrincipalId;
use laser_sdk::rbac::{Action, Effect, Feature, Grant, ResourcePattern, Role};

let role = Role {
    name: "support-reader".to_owned(),
    grants: vec![Grant {
        effect: Effect::Allow,
        feature: Feature::Kv,
        action: Action::Read,
        resource: ResourcePattern::prefix("stream:support/"),
    }],
};

laser.define_role(role).await?;
laser.bind_roles(PrincipalId::new(7), vec!["support-reader".to_owned()]).await?;
import laser_sdk as ls

grants = [
    ls.Grant(
        "kv",
        "read",
        effect="allow",
        resource=ls.ResourcePattern.prefix("stream:support/"),
    )
]

await laser.define_role("support-reader", grants)
await laser.bind_roles(7, ["support-reader"])

The resource is stream:support/ because managed names are scoped to the connection's default stream by default. laser.kv("tickets") on a connection whose default stream is support addresses stream:support/tickets. A role written for bare names such as support/ only matches clients that use bare resource naming. See Stream-scoped resource names.

Role operations

OperationWhat it doesNeeds
whoami()Return the caller's roles and effective grantsAny caller
list_roles, get_role, get_bindingsRead roles and one user's bound role namesauthz:read
define_role, delete_role, bind_rolesChange roles and bindingsauthz:admin
authz_historyRead the change history of roles and bindingsauthz:read

TypeScript uses camelCase for every name. The authz_history subject is all changes, one role, or one user's binding. Python passes it as "all", {"role": name}, or {"binding": {"user_id": id}} with after_revision= and limit= keywords, and the limit defaults to 100. Rust takes (subject, after_revision, limit). TypeScript takes (subject, limit, afterRevision?). list_roles takes a name prefix and a search substring in every SDK: Rust list_roles(name_prefix, search), TypeScript listRoles({ namePrefix, search }), and Python list_roles(name_prefix=None, *, search=None).

A binding update can be guarded by revision. Rust uses bind_roles_expect_revision, TypeScript passes expectRevision as the third argument of bindRoles, and Python passes expect_revision= to bind_roles. The update applies only if the binding's current revision equals the one you pass. A mismatch fails with a conflict that carries the current revision: AuthzError::Conflict { current_revision } in Rust, an AuthzExecutionError whose detail has currentRevision in TypeScript, and an AuthzError with detail in Python. get_bindings returns role names only, so take the revision from the conflict or from authz_history.

New Iggy users receive no managed role. The default administrator has a reserved admin role for first setup. You can also bind roles in the Console.

Grant rules

  • The features are kv, kv_lease, kv_fence, memory, projection, fork, graph, query, destination, checkpoint, filter, session, and authz. The retired agent and workflow features match no operation. A role that granted agent:* should grant session:* instead.
  • The actions are read, write, delete, and admin. An unknown feature or action word decodes as unrecognized and matches nothing.
  • A resource pattern is all, literal, or prefix. A prefix is a plain string prefix, so stream:acme also matches stream:acme-staging. Only all matches a request with no keyed resource, such as a list call.
  • A deny wins over an allow for the same feature, action, and resource match. This rule cannot be turned off.
  • Lease control is its own feature. A kv:write grant never lets a holder acquire, renew, or release a lease. That needs kv_lease:admin on the coordination namespace. A fenced write needs kv:write on the target and kv_fence:read on the coordination namespace.
  • Role names are 1 to 64 bytes of ASCII letters, digits, -, _, and .. An invalid name fails in the client before any request.
  • Managed grants are separate from Iggy permissions. The server enforces them on managed commands.
  • Session reads use the session feature on the resource stream:<name>. session:read lists and reads sessions, and the caller also needs read permission on the whole stream. session:admin registers a stream as a session source. session:write covers key-value and graph writes linked to a session, and also needs native send permission on that stream's agent.sessions.
  • A deployment in stream tenancy mode accepts only grants that stay inside one stream. It refuses a whole-feature grant or a prefix that spans streams with a tenancy violation error, and it rejects an unscoped resource name.

Without the authz capability on the server, role calls fail in the client with an unsupported error before anything is sent. Server-side failures are Unsupported, Unauthorized, UnknownRole, InvalidName, Conflict, Version, and TenancyViolation. In TypeScript a server unsupported becomes UnsupportedError and the rest become AuthzExecutionError.

Act for a user

When an agent acts for a user, both grant sets must allow the action. A user session cannot extend the agent's grants, and the user's restrictions still apply through the agent.

import { delegatedAllow } from "@laserdata/laser-sdk"

const allowed = delegatedAllow(
  agentGrants,
  userGrants,
  "kv",
  "write",
  "stream:support/tickets/acme"
)
use laser_sdk::rbac::{Action, Feature, delegated_allow};

let allowed = delegated_allow(
    &agent_grants,
    &user_grants,
    Feature::Kv,
    Action::Write,
    Some("stream:support/tickets/acme"),
);
allowed = ls.delegated_allow(
    agent_grants,
    user_grants,
    "kv",
    "write",
    "stream:support/tickets/acme",
)

grants_allow (TypeScript: grantsAllow) applies the same deny-wins rule to one grant set. A missing resource means the operation has no keyed resource. In Rust both helpers also live in laser_sdk::wire::authz, which needs no rbac feature. verify_delegation checks a signed delegation on an envelope and returns the signer and the delegated user, which you then pass to delegated_allow. Interop lists it.

To check the audience and scope of an A2A or MCP request token before it reaches a bridge, use authorize_edge. Interop shows it in all three languages.

ActionGovernor

ActionGovernor is a policy hook in your process that runs before an effect. Roles decide what a principal may do at all. The governor decides whether one specific effect may run now, for example by size, rate, or payload content. It can restrict what the server allows. It cannot widen it.

laser.with_governor(governor, mode) returns a governed clone of the connection (TypeScript: withGovernor). Agents spawned from the governed connection inherit it. An agent can take its own governor through the agent builder or spawn_agent(governor=..), which replaces the connection's governor for that agent. The governor sees agent sends, typed and raw topic publishes, requests, AGDX verbs, and memory writes.

import { ActionDecision, type ActionGovernor, GovernorMode } from "@laserdata/laser-sdk"

const sizeLimit: ActionGovernor = {
  decide: async (action) =>
    action.payload.length > 65_536
      ? ActionDecision.block("payload over 64 KiB")
      : ActionDecision.allow()
}

const governed = laser.withGovernor(sizeLimit, GovernorMode.Enforce)
use laser_sdk::prelude::full::*;
use std::sync::Arc;

struct SizeLimit;

#[async_trait::async_trait]
impl ActionGovernor for SizeLimit {
    async fn decide(&self, action: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
        Ok(if action.payload.len() > 65_536 {
            ActionDecision::block("payload over 64 KiB")
        } else {
            ActionDecision::allow()
        })
    }
}

let governed = laser.with_governor(Arc::new(SizeLimit), GovernorMode::Enforce);
class SizeLimit:
    async def decide(self, action):
        if len(action.payload) > 65_536:
            return ls.ActionDecision.block("payload over 64 KiB")
        return ls.ActionDecision.allow()

governed = laser.with_governor(SizeLimit(), "enforce")

The Rust trait uses #[async_trait], so add the async-trait crate to your dependencies. Python accepts any object with a decide method or a plain callable, synchronous or asynchronous.

The governed action carries its kind, stream, topic, source, target, conversation, correlation, operation, tool, payload, whether it is signed, the delegated user (on_behalf_of), the advisory purpose and data_classification claims, and session counters in action.counters (sends, requests, and bytes_sent, TypeScript: bytesSent). sends and requests count every decided effect, blocked ones included. bytes_sent counts only effects that ran, with the replacement body when one was modified.

Verdicts

VerdictEffect in enforce mode
allowRuns the effect. Records no evidence
observeRuns the effect and records evidence
blockRejects the effect with PolicyBlocked (TypeScript and Python: PolicyBlockedError)
step_up(scope)Rejects the effect with StepUpRequired { scope } (TypeScript and Python: StepUpRequiredError with scope)
modify(body)Replaces the body, then runs the effect. The replacement applies before a large body is moved out by claim check and before signing
deferRejects the effect with PolicyDeferred (TypeScript and Python: PolicyDeferredError), which is retryable

The factories are ActionDecision.allow(), observe(), block(reason), step_up(scope) (TypeScript: stepUp), modify(body), and defer(reason). with_reason and with_risk_score (TypeScript: withReason, withRiskScore) add details that the evidence records. A blocked or deferred effect fails with a fixed message. The governor's reason appears only in the evidence.

A decision carries its verdict as a Verdict, read as a string with as_str() (TypeScript: verdictAsStr(verdict)). with_policy(PolicyRef { pack_id, pack_version, rule_ids }) pins the policy pack that decided (TypeScript: withPolicy({ packId, packVersion, ruleIds }), Python: with_policy(ls.PolicyRef(pack_id, pack_version, rule_ids))).

Modes

The mode is enforce or observe. Python defaults to enforce, and Rust and TypeScript require you to pass it. Enforce applies the verdict. Observe runs every effect with its original body and records what enforce would have done, with outcome effected. Introduce a policy in observe mode, read the evidence, then switch to enforce. An error from the governor itself fails the call in both modes, so a broken governor never lets an effect through.

Evidence

Every decision that is not allow is recorded as policy evidence: an AGDX event with operation policy_decision on the agent.audit topic of the governed action's stream. Its decision names the verdict and its outcome names what happened to the effect: effected, blocked, step_up, or deferred. The evidence source is the acting agent, or governor when there is none.

  • agent.audit is not created by the governor. Run bootstrap first, and give the caller send permission on it.
  • In enforce mode, a failed evidence write stops an otherwise allowed effect. A denial stands whether or not its evidence was stored.
  • In observe mode, a failed evidence write never stops the effect. Rust and Python log a warning, and TypeScript emits a LaserWarning with the same text.
  • Records chain by digest for each governed connection and conversation. Each with_governor call, and each agent with its own governor, starts its own chain.
  • The governor keeps one chain head per conversation in memory, at most 4,096 heads by default, and evicts a head after one idle hour. A head in use is never evicted. An eviction or a restart starts a new chain for that conversation.

Rust and Python decode an evidence body with PolicyEvidence::decode and PolicyEvidence.decode, and encode it with encode(). TypeScript uses encodePolicyEvidence and decodePolicyEvidence. verify_evidence_chain(evidence) (TypeScript: verifyEvidenceChain) takes records in log order and reports whether each reproduces its own digest and names the previous one. The first record must have no previous digest. Run it over one chain, because two governed connections writing the same conversation interleave two chains on agent.audit.

WhereRustTypeScriptPython
Connectionwith_governor(governor, mode)withGovernor(governor, mode)with_governor(governor, mode)
Connection with retentionwith_governor_retention(governor, mode, GovernorRetention { capacity, idle_ttl })withGovernor(governor, mode, { capacity, idleTtlMs })with_governor_retention(governor, mode, ls.GovernorRetention(capacity=, idle_ttl_ms=))
Connect optionsgovernor(..), governor_with_retention(..) on the buildergovernor(policy, mode, retention?) on the builderconnect(..., governor=, governor_mode=, governor_retention=)
Agentgovernor((governor, mode)), governor_retention(..)governor([governor, mode]), governorRetention({ capacity, idleTtlMs })governor=, governor_mode=, governor_retention=(capacity, idle_ttl_ms)

All three raise a capacity of 0 to 1. TypeScript throws InvalidError for a capacity or an idleTtlMs that is negative or not a safe integer.

Combine and swap governors

QuorumGovernor runs several governors at once and combines their verdicts. allow, observe, and modify count as yes. block, step_up, and defer do not. The policy is All, Any, or AtLeast(n) (TypeScript: { kind: "all" }, { kind: "any" }, or { kind: "at-least", required: n }, Python: QuorumPolicy.all(), .any(), or .at_least(n)).

The combined decision follows these rules, in order:

  • An empty voter set, or a threshold of zero or above the voter count, blocks.
  • A repeated voter name blocks.
  • A mandatory voter that raises an error blocks. An optional voter that raises an error counts as an abstention.
  • A mandatory voter must say yes under every policy. The first mandatory voter that does not is returned as it is. A mandatory defer wins even when an optional voter said block.
  • Conflicting body replacements block.
  • When the quorum passes, the strongest yes wins: modify, then observe, then allow. When it fails, the most actionable denial wins: block, then step_up, then defer.

The decision reason lists what each voter said. The mandatory argument of voter is required in every SDK, and voter returns the governor, so enrollments chain.

SwappableGovernor replaces the active policy at runtime without a reconnect. swap(next) returns the previous policy and current() returns the active one. A decision already in flight finishes under the policy it read, and a swap never reinterprets evidence already on the log.

import { QuorumGovernor, SwappableGovernor } from "@laserdata/laser-sdk"

const audit: ActionGovernor = { decide: async () => ActionDecision.observe() }

const quorum = new QuorumGovernor({ kind: "all" })
  .voter("size", sizeLimit, true)
  .voter("audit", audit, false)

const swappable = new SwappableGovernor(quorum)
const governed = laser.withGovernor(swappable, GovernorMode.Enforce)

const previous = swappable.swap(sizeLimit)
const active = swappable.current()
use laser_sdk::govern::{QuorumGovernor, QuorumPolicy, SwappableGovernor};

struct Audit;

#[async_trait::async_trait]
impl ActionGovernor for Audit {
    async fn decide(&self, _action: &GovernedAction<'_>) -> Result<ActionDecision, LaserError> {
        Ok(ActionDecision::observe())
    }
}

let quorum = QuorumGovernor::new(QuorumPolicy::All)
    .voter("size", Arc::new(SizeLimit), true)
    .voter("audit", Arc::new(Audit), false);

let swappable = Arc::new(SwappableGovernor::new(Arc::new(quorum)));
let governed = laser.with_governor(swappable.clone(), GovernorMode::Enforce);

let previous = swappable.swap(Arc::new(SizeLimit));
let active = swappable.current();
class Audit:
    async def decide(self, action):
        return ls.ActionDecision.observe()

quorum = ls.QuorumGovernor(ls.QuorumPolicy.all())
quorum.voter("size", SizeLimit(), True)
quorum.voter("audit", Audit(), False)

swappable = ls.SwappableGovernor(quorum)
governed = laser.with_governor(swappable, "enforce")

previous = swappable.swap(SizeLimit())
active = swappable.current()

Session budgets and control

A session budget states the tokens and cost one session may use. Set it on a session builder or on a submission. Cost is an integer in micro-units of the deployment's currency, so sums are exact. A budget does not decide who can submit work or what the work can reach. Native permissions and managed grants do that.

const submitted = await laser
  .sessions()
  .submit(AgentId.new("auditor"), new TextEncoder().encode("audit incident 7"))
  .from(AgentId.new("governance"))
  .budget({ tokens: 4_000n, costMicros: 2_000_000n })
  .send()

const over = await laser.sessions().open(submitted.session).overBudget()
use laser_sdk::wire::agent::Budget;

let submitted = laser
    .sessions()
    .submit("auditor".parse::<AgentId>()?, b"audit incident 7".to_vec())
    .from("governance".parse::<AgentId>()?)
    .budget(Budget {
        tokens: Some(4_000),
        cost_micros: Some(2_000_000),
    })
    .send()
    .await?;

let over = laser.sessions().open(submitted.session).over_budget().await?;
submitted = await (
    laser.sessions()
    .submit("auditor", b"audit incident 7")
    .from_("governance")
    .budget(ls.Budget(tokens=4_000, cost_micros=2_000_000))
    .send()
)

over = await laser.sessions().open(submitted.session).over_budget()

over_budget() on a session handle answers from the managed session index when the deployment has one, and otherwise folds the session's records on agent.sessions, so it also works on plain Apache Iggy. sessions().get(id) returns the same flag as over_budget but needs the managed index.

The platform never writes your topics, so the flag never ends a session by itself. The SDK acts on it in two places:

  • On a deployment that indexes sessions, an agent with an ID checks each session before its handler runs. It fails an over-budget session once with reason budget, then commits its work without calling the handler. If reading the index fails, the record is handled. Plain Apache Iggy does not get this check.
  • A workflow writes the token cap of its WorkflowBudget as the budget of its run session and checks it at every step boundary, on any server. A breach runs the compensations, fails with a budget exceeded error, and ends the run session failed with reason budget. See Advanced agents.

Operators stop sessions through agent.control. Native send permission on that topic is the authority boundary, so give it to operator accounts only. A cancel, pause, or resume found on any other topic is never applied. signed_by(key) (TypeScript: signedBy) on the control handle signs each control record, and on a session handle signs the session's terminal record. The managed session index verifies signed records against the stream's key registry and names the signer as verified_actor. Without a signature, operator and author names stay claims. In Rust, signed_by needs the sign feature. Advanced sessions covers pause, resume, cancel, and force cancel.

Durable approval records

Approvals use typed Intent, Vote, and Decision records. The application publishes them and folds the recorded votes into a decision. Apply the effect only when the decision authorizes the intent.

import { AgentId, ConversationId, Intent, Vote, VoteChoice, decide } from "@laserdata/laser-sdk"

const now = BigInt(Date.now()) * 1_000n
const safety = AgentId.new("safety")
const intent = new Intent({
  conversation: ConversationId.new(),
  proposer: AgentId.new("planner"),
  body: new TextEncoder().encode("rotate the storage credentials"),
  eligibleVoters: [safety],
  policy: { kind: "all" },
  policyVersion: 7n,
  deadlineMicros: now + 30_000_000n
})

const vote = Vote.cast(intent, safety, VoteChoice.Allow)
const decision = decide(intent, [vote], BigInt(Date.now()) * 1_000n)
if (decision?.authorizes(intent)) {
  // apply the fenced effect, then persist the decision
}
use laser_sdk::agent::{Clock, SystemClock};
use laser_sdk::intent::{Intent, IntentPolicy, Vote, VoteChoice, decide};
use laser_sdk::prelude::{ConversationId, LaserError};

fn approve() -> Result<(), LaserError> {
    let now = SystemClock.now_micros();
    let intent = Intent::builder()
        .conversation(ConversationId::new())
        .proposer("planner".parse()?)
        .body(b"rotate the storage credentials".to_vec())
        .eligible_voters(vec!["safety".parse()?])
        .policy(IntentPolicy::All)
        .policy_version(7)
        .deadline_micros(now + 30_000_000)
        .build()?;

    let vote = Vote::cast(&intent, "safety".parse()?, VoteChoice::Allow)?;
    if let Some(decision) = decide(&intent, &[vote], SystemClock.now_micros())? {
        if decision.authorizes(&intent)? {
            // apply the fenced effect, then persist the decision
        }
    }
    Ok(())
}
import time

now = time.time_ns() // 1_000
intent = ls.Intent(
    conversation=ls.new_conversation_id(),
    proposer="planner",
    body=b"rotate the storage credentials",
    eligible_voters=["safety"],
    policy=ls.IntentPolicy.all(),
    policy_version=7,
    deadline_micros=now + 30_000_000,
)

vote = ls.Vote.cast(intent, "safety", "allow")
decision = ls.decide(intent, [vote], time.time_ns() // 1_000)
if decision and decision.authorizes(intent):
    # apply the fenced effect, then persist the decision
    pass

The Rust intent calls return IntentError, which converts into LaserError::Invalid, so ? works in a function that returns LaserError. In TypeScript IntentError is a subclass of InvalidError. In Python it is IntentError(InvalidError) with kind constants.

An intent has an eligible voter list, an optional mandatory voter list, a policy (All, Any, or AtLeast(n)), a policy version, and a deadline in epoch microseconds. A vote is allow, block, or abstain, and names the digest and policy version of the intent it answers. Building an intent mints its ID, computes its digest, and stamps the proposal time now. Vote.cast stamps now too. No SDK takes a caller-supplied ID, digest, or time when it builds one.

Building or validating an intent fails for an empty eligible list, a repeated eligible or mandatory voter, a mandatory voter who is not eligible, an AtLeast(n) threshold that is zero or above the eligible count, a deadline that is not after the proposal time, and a digest that does not match the body. Call validate() before publishing an intent you built or changed outside the validated constructors. Vote::cast fails for a voter outside the eligible list.

decide folds the votes with these rules:

  • It ignores votes from voters who are not eligible, votes for another digest or policy version, votes outside the window from the proposal time to the deadline, and votes stamped after now.
  • The same vote repeated counts once. A voter who changes their choice aborts the intent.
  • A mandatory voter's block or abstain aborts at once. Every mandatory voter must vote allow before the intent commits.
  • Under All, any vote that is not allow aborts at once.
  • It returns nothing while the outcome can still change. Otherwise it returns a Decision that is committed or aborted. After the deadline it always returns one.

authorizes(intent) fails when the intent is invalid or the decision does not match the intent's ID, digest, and policy version. Otherwise it reports whether the outcome is committed. A voter name is a claim inside a record. Use signed principals or topic permissions when you must trust voter identity.

Where it runs

Role, binding, and history calls need a server that advertises the authz capability, as Laser Stack and LaserData Cloud do. Session listing and sessions().get need the sessions capability. ActionGovernor, delegated_allow, grants_allow, over_budget(), the workflow budget check, and intent records work on plain Apache Iggy.

In Rust, governors, intents, and sessions need the agent feature, roles need rbac, and signing needs sign. managed includes rbac but not agent or sign. authorize_edge needs a2a-bridge or mcp-bridge.

On this page