Skip to main content

Field & Behavior Reference

This page lists every field the Developer Edition accepts, what each one does, and what it does not do. The second part matters: a field that looks like a control but enforces nothing is dangerous, because you may rely on it without knowing it has no effect. So every field below carries one of these labels:

  • Enforced means the engine reads the field and it can change the decision.
  • Advisory means the field is accepted and passed along (for example into lineage), but it does not limit the decision in the Developer Edition.
  • Platform means the field is filled in and enforced by the governed control plane (Watchlight Beacon). In the in-process engine it has no effect, or holds a fixed placeholder value.

Everything on this page was checked against the published watchlight-engine wheel.

authorize request​

An authorization request names who is acting (principal), what they want to do (action), what they want to do it to (resource), and optionally extra facts (context) and a declared purpose (intent):

{ "principal": "...", "action": "...", "resource": "...",
"context": { ... }, "intent": { ... } }

The table describes how the engine treats each field.

FieldStatusNotes
principalEnforcedA typed Cedar entity reference: User::"alice", Agent::"<name>". Accepted types are User, Agent, Group, Role, Tool, Resource, Workflow; any other refuses the call (AuthorizeRequestError, audited as a Deny). A bare untyped string matches a policy naming that id under User, Agent, Group or Role, and not under Tool, Resource or Workflow; when it matches more than one, an allow beats a forbid. Empty, whitespace-only or control-character values raise. Omitted, the SDK sends Agent::"<agent-name>". See the identity model.
actionEnforcedMatched as Action::"<value>".
resourceEnforcedMatched as Resource::"<value>".
context.<key>EnforcedRead by Cedar when / unless. A missing key referenced by a forbid denies — a deliberate deviation from raw Cedar.
context.actorReserved · EnforcedThe acting agent, set by the SDK from the governor's name (RFC 8693 act.sub). Read it as context.actor == "<name>". A caller value that differs raises ReservedContextError; an identical one is accepted.
context.actor_chainReserved · EnforcedThe delegation chain, root first, set-valued: context.actor_chain.contains("<name>"). Only delegate builds it, so a caller can neither supply nor extend it. At most 6 entries.
context.agentNot usableThe engine overwrites this key with an object, so a policy comparing it to a string never matches and the call silently denies. Use context.actor.
context.agent.anomaly_scorePlatformBehavioral drift score, supplied by the engine and unforgeable. Stubbed at 0 in DE.
context.agent.quarantinedPlatformSet by the platform's quarantine pipeline; always false in DE.
intent.categoryEnforcedDrives the intent rules below.
intent.risk_levelEnforcedcritical escalates (below).
intent.objective / justificationEnforcedSome categories require a justification (below).

Intent enforcement (above Cedar)​

The engine makes four decisions no policy expresses. They fire even under permit(principal, action, resource), so account for them when you predict a verdict from your own inputs.

IntentBehavior
financial_operationEscalates to human approval (Deny + escalation_required)
risk_level: criticalEscalates to human approval
system_administrationDenied unless a justification is supplied
security_complianceDenied unless a justification is supplied

attenuate_scope (sub-agent confinement)​

When an agent hands work to a sub-agent, it gives the sub-agent a narrower scope derived from its own. The engine checks each field of the new scope against the parent's, so a sub-agent can never hold more authority than the agent that created it:

FieldStatusNotes
allowed_toolsEnforcedChild must be a subset of the parent's.
allowed_intentsEnforcedChild must be a subset.
allowed_resourcesEnforcedSubset by exact string; see the matcher note below.
max_depthEnforcedA decrementing budget: a child's max_depth must be ≤ parent − 1; 0 means the scope can no longer delegate.
time_budget_secondsEnforcedClamped to the parent's remaining budget and deadline.
depthAdvisoryCarried into the granted scope for lineage. In DE it is not cross-checked against max_depth — don't rely on it as a ceiling; max_depth is the enforced control.
Resource matchers are exact strings, not globs

Only the bare * is a wildcard. tickets/* is treated as a literal string — tickets/* does not match tickets/123. Model allowed_resources as an enumeration of exact values (or * for "any"), not as glob patterns.

Scope tokens (to_token / scope_from_token)​

Scope.to_token() serialises an attenuated scope for another process; Watchlight.scope_from_token(token) (TS: scope.toToken() / await govern.scopeFromToken(token)) re-establishes it. A token is shaped wls1.<base64url(canonical JSON claims)>.<base64url(HMAC-SHA256)>, and the HMAC covers "wls1." + <payload segment> under the shared secret.

When a governor receives a token, it runs every check below. If any check fails, the token is refused.

CheckStatusNotes
SecretRequiredsigning_secret / signingSecret or WATCHLIGHT_SIGNING_SECRET, at least 16 bytes. No default — mint and verify raise ScopeTokenError("no_secret") without one. Takes an ordered list to rotate without a cutover: see the signing secret.
LengthEnforcedTokens over 16 384 characters are rejected unparsed.
VersionEnforcedOnly wls1; the prefix is part of the signed bytes.
EncodingEnforcedStrict base64url, no padding, canonical; the payload must be canonical JSON (sorted keys, no whitespace, set arrays sorted by code point).
SignatureEnforcedConstant-time compare, checked before the payload is parsed.
SchemaEnforcedExactly agent, root, chain, depth, iat, exp; unknown fields, non-integers, depth ≠ len(chain), root.max_depth > 64, or len(chain) > 64 are rejected — a structural bound; the receiving governor's max_delegation_depth also applies.
IdentityEnforcedagent must equal the verifying governor's agent.
TimeEnforcediat at most 60 s in the future; exp in the future; exp − iat ≤ the deepest level's time_budget_seconds. Minting caps exp at the scope's remaining lifetime.
SubsetEnforced by the engineThe root is rebuilt and every chain level is replayed through attenuate_scope; a widened level raises AttenuationDenied, and a grant that differs from the claimed level is rejected (mismatch).
ExpiryEnforcedA rebuilt scope's expires_at is clamped to exp; past it, attenuate() / to_token() raise ScopeTokenError("expired"); assert_active() / .expired for callers holding a scope across time.

A shared secret is integrity within one trust domain, not attestation. The root is rebuilt from the token, so anyone holding the secret can mint any scope, root included. See scope attenuation.

Reject unknown fields​

An unrecognised key — a typo like max_dept, or a field the engine does not read — is ignored, so a misspelled constraint silently does nothing. Keep request and scope objects minimal, and diff them against the tables above.

MCP PEP (watchlight-mcp)​

The watchlight-mcp package has three entry points that put a policy enforcement point (PEP) in front of an MCP server: serve() for an HTTP server, serve_background() for the same thing on a background thread, and serve_stdio() for a server that speaks over standard input and output. The full walkthrough, configuration and error codes are on Govern an MCP server. These are the signatures:

# Front a Streamable-HTTP MCP server (HTTP↔HTTP proxy). tls_cert/tls_key
# terminate downstream TLS; upstream_ca trusts a private https upstream
# (the three TLS parameters require watchlight-mcp >= 0.4.0):
serve(listen_addr, upstream_url, upstream_server,
policy_files=None, audit_path=None, upstream_timeout_ms=30000,
tls_cert=None, tls_key=None, upstream_ca=None,
principal_header=None, allow_unattributed=False) # last two: watchlight-mcp >= 0.4.4

# Non-blocking variant — returns a handle for hot policy reload + shutdown
# (requires watchlight-mcp >= 0.3.0). Takes the same parameters as serve();
# its TLS parameters, like serve()'s, require watchlight-mcp >= 0.4.0:
serve_background(...) -> PepHandle # .reload_policies(files), .stop(), .running

# Govern a stdio-launched MCP server — spawns it as a child, relays JSON-RPC
# over stdin/stdout (requires watchlight-mcp >= 0.2.0):
# Since 0.4.4 exactly one of agent_id / principal_id is required, unless
# allow_unattributed=True:
serve_stdio(upstream_command, upstream_server, agent_id=None,
principal_id=None, allow_unattributed=False,
policy_files=None, audit_path=None)

All three authorize every governed call (tools/call, resources/read, resources/subscribe and prompts/get) inside the PEP's process before the call reaches the server. When a call is refused, the MCP client receives a JSON-RPC error code, not a Python exception. Error -32001 means a policy denied the call, and error -32002 means the call's identity was missing or refused. Every governed call needs an identity; see Identity.

serve() and serve_background() themselves raise a Python exception when they cannot start, for example when a policy file cannot be read or does not match the schema, or when the address is already in use.

Egress hook (on_result / onResult)​

You bind the hook as @govern.tool(intent, ..., on_result=hook) in Python, or govern.tool(fn, { intent, onResult }) in TypeScript. It works the same way on governTool / governTools and governedHooks (Claude PostToolUse). The hook receives (result, info) after the body returns and before the caller or model sees the result. info is {intent, resource, principal, decision_id} (TS: decisionId), plus obligations when the decision that let the body run carries any.

What happens next depends on what the hook does:

Hook outcomeStatusEffect
returns a valueEnforcedThe value replaces the payload the caller / model receives.
returns None / undefined / nullEnforcedThe payload passes through unchanged.
raises / throwsEnforced (fail-closed)The payload is withheld and the raw result is never returned. The Claude PostToolUse hook cannot throw back to the SDK, so the model receives the opaque "not authorized" string instead.
outruns onResultTimeoutMs (default 8000 ms)Enforced (fail-closed)Same as a throw: EgressTimeout, withheld: true. A hook that settles later is discarded. The deadline cannot be switched off.
async hook on a synchronous body (Python)Enforced (fail-closed)TypeError, and withheld: true. An async hook needs an async tool body.
info.decision_idEnforcedThe same id as the decision line that let the body run — the approved decision on a human-in-the-loop path. Pass it to sanitize(..., decision_id=) and screen(..., decision_id=) inside the hook so their records join too.
info.obligationsEnforcedThe obligations of that decision (see Obligations); present only when the Allow carries any. A missing obligation is no permission, not no limit.

Every run writes a value-free egress audit record: {ts, agent, principal, intent, event: "egress", resource, replaced, decision_id} plus withheld: true when the payload was held back. It never carries the result or anything derived from it. No hook, no egress record.

Framework adapters — what each one can express​

An adapter must reach the same verdict a hand-written governed tool reaches for the same inputs. Where it cannot, the rule fails closed and silently: a policy reading a term the adapter never carried denies, and that denial looks exactly like a policy denial.

AdapterIntentSubjectResourceCedar contextHuman-in-the-loopEgress
govern.tool() / @govern.toolintentprincipalresourcecontextonNeedsApproval / on_needs_approvalonResult / on_result
governTool(tool, …) (TS)intentprincipalresourcecontextonNeedsApprovalonResult
governTools(tools, …) (TS)intentFor(name)principalresourceFor(name)contextonNeedsApprovalonResult
governedHooks(…) (TS)intentFor(name)principalresourceFor(call)contextonNeedsApprovalonResult, onResultTimeoutMs
governed_plugin() (Python)per call, authorize_action(action, …)per call, authorize_action(…, principal=…) — needs watchlight-agent-sdk ≥ 0.7.0per call, authorize_action(…, resource)per call, authorize_action(…, context=…)authorize_action_detailed → needs_approval—

Bindings. On governTool / governTools, principal, resource and context are each a fixed value or a function of the tool's own invoke(input, config) arguments. On governedHooks, principal and context are a value or a function of the call the SDK is about to make, ({ toolName, toolInput }) => value, and resourceFor is ({ toolName, toolInput }) => string | undefined. Both TS adapters also take agent, the per-adapter rename that lands on the record and in context.actor.

Defaults. No principal → the agent, Agent::"<name>". No resource / resourceFor → tool/<name>. No context → empty, so a rule reading context.* denies. A single shared resource is deliberately not offered on governTools or governedHooks: it would collapse every tool onto one anchor. Map per tool with resourceFor, returning undefined to keep the default.

Approval. governTool raises NeedsApproval carrying the decision id, as govern.tool() does; governedHooks denies the call. In both, an onNeedsApproval returning true re-authorizes under a single-use token.

The Python plugins are constructor wiring. governed_plugin() builds a published framework plugin and hands it the in-process engine. Every per-call term goes on its run handle, the subject included:

async with await plugin.start_run("support-agent") as handle:
ok = await handle.authorize_action(
"read_ticket", "tool/read_ticket",
principal=f'User::"{user_id}"',
context={"caller": caller, "owner": owner},
)

Omitted, the subject is the agent that runs — Agent::"<agent uuid>". governed_plugin() refuses principal=, context= and resource= at construction with a TypeError: all three belong to a call.

Name the entity type on this path. principal, resource and the action reach the engine exactly as written, and a typed reference discriminates: User::"u-1" is not matched by a policy naming Agent::"u-1". A bare name matches User, Agent, Group and Role policies for that id. The action is always Cedar type Action; a policy giving it another type is rejected.

This path collapsed every term to a bare name before watchlight-agent-sdk 0.7.0. Upgrading is a tightening — see breaking changes.

Obligations (obligations on an Allow)​

A permit annotated with @obligate_* yields an Allow that carries structured constraints the caller must honour. The engine parses a closed vocabulary at load time — a malformed value rejects the policy — and the SDK surfaces it as result["obligations"] (Python) / d.obligations (TS). The field appears only on an Allow that carries at least one obligation, an approved human-in-the-loop Allow included. Never on Deny or NeedsApproval.

AnnotationPythonTypeScriptStatusNotes
@obligate_redact("ssn, dob")redact: ["ssn","dob"]redactEnforced by youField names to strip before the result reaches the caller or the model. Comma-separated, trimmed, de-duplicated, at least one entry.
@obligate_max_items("25")max_items: 25maxItemsEnforced by youUpper bound on how many items the caller may act on or return. Integer 1..=4294967295.
@obligate_log_values("false")log_values: falselogValuesEnforced by youWhether the values handled under this decision may be logged. Exactly true / false.
@obligate_<name>("raw")extra: {name: ["raw"]}extraAdvisoryAny other key, passed through raw for your own code to interpret: per name, the sorted distinct values every carrying permit declared. Never interpreted.

"Enforced by you" means the engine guarantees the field is present and exact. The SDK does not itself redact, cap or silence a logger — read the field in your code or in the on_result / onResult hook. Treat a missing obligation as no permission, not no limit (maxItems ?? 0).

Worth knowing:

  • When several policies carry obligations, the strictest combination wins. redact becomes the union of all the lists, max_items the smallest value, and log_values is true only if every policy says true. extra[name] keeps the sorted distinct values from all of them.
  • An obligation that cannot be read blocks the call. If max_items is outside 1..=4294967295, log_values is not a boolean, or redact is not a non-empty list of non-blank strings, the SDK raises AuthorizeError and the tool body does not run.
  • Obligations need engine 0.2.0 or later. An older engine does not return the field at all.
  • Obligations are not logged. They are strings written by the policy author and returned exactly as written, and nothing about them enters the audit trail.
  • A govern.test fixture may state "obligations": {...} — see testing your policies. Pattern: allow but redact.

Exceptions​

The pure-Python API (pip install watchlight) signals a refusal by raising — the decorated body or sub-agent spawn never runs. Catch the specific type; each carries structured attributes so you can report exactly why.

ExceptionBaseRaised whenAttributes
DeniedPermissionErrorA @govern.tool call is refused (fail-closed: no matching permit, or an explicit deny)..tool, .intent, .reason
AttenuationDeniedPermissionErrorScope.attenuate(...) requests authority the parent doesn't hold (not a strict subset)..violations (e.g. ['AllowedTools']), .reason
DelegationDepthExceededAttenuationDeniedScope.attenuate(...) or delegate would take the tree past max_delegation_depth. A deny, recorded in the audit trail with the observed depth and the limit..code ("DELEGATION_DEPTH_EXCEEDED"), .depth, .limit, .violations (['MaxDepth'])
PolicyCompileErrorRuntimeErrorallow, load or reload is given a policy the engine cannot compile. Nothing from that call is added, and reload keeps the old set. In TypeScript it surfaces at await govern.ready() or the next decision, and every decision throws it until a reload succeeds. Since 0.13.0..policy, .source
ScopeTokenErrorPermissionErrorScope.to_token() / Watchlight.scope_from_token() cannot mint or refuses a token: no secret configured, malformed, unknown version, bad signature, wrong agent, future iat, expired, over-long lifetime, or an engine grant that differs from the claim..code (no_secret, weak_secret, too_large, malformed, version, signature, identity, future_iat, expired, lifetime, mismatch)
from watchlight import Denied, AttenuationDenied, DelegationDepthExceeded

try:
web_search("…") # a governed tool
except Denied as e:
print(e.intent, e.tool, e.reason) # report and stop — a policy must change

Denied and AttenuationDenied both subclass PermissionError, so a single except PermissionError catches every authorization refusal if you don't need to distinguish them. You import them from the top-level watchlight package.

Over the MCP PEP (watchlight-mcp) the same refusals arrive as JSON-RPC error codes instead of exceptions — see Govern an MCP server.

Approval tokens​

A permit annotated @enforcement_effect("require_approval") yields NeedsApproval. After a human confirms, govern.mint_approval(action=, principal=, resource=) / govern.mintApproval({ action, principal?, resource? }) mints a token to pass back to authorize. The token is <exp>.<nonce>.<hmac-sha256>, bound to that exact (principal, action, resource), single-use, and TTL-bounded (2 minutes by default).

Two things decide where a token is valid, and both defaults are per-process:

DefaultWhat it meansConfigure
Signing keyA random key minted at importA token cannot cross a process boundary, and a restart invalidates every outstanding approvalapproval_secret= / approvalSecret
Single useAn in-process map of reserved idsAtomic within the process (N concurrent consumes of one token yield exactly one Allow), but behind two replicas the same token can be consumed once on each — single-use is per-replica, not per tokenapproval_store= / approvalStore

Both defaults are correct for an agent that runs as a single process. They are wrong for an agent that runs as several replicas, and the SDK never switches to a shared setting on its own, so a replicated agent must configure both.

class RedisApprovalStore:
def __init__(self, redis):
self._redis = redis

# ONE method, and the check and the set are the SAME step: `SET … NX`
# reserves the id only if it is absent, and a None reply means it was
# already there — this consume is a replay, so the approval is refused.
def add(self, id, expires_at): # -> bool
return self._redis.set(f"wl:appr:{id}", "1", nx=True, pxat=expires_at) is not None


govern = Watchlight(
approval_secret=os.environ["APPROVAL_SECRET"], # >= 16 bytes
approval_store=RedisApprovalStore(redis), # add(id, expires_at) -> bool
)
const govern = new Watchlight({
approvalSecret: process.env.APPROVAL_SECRET,
approvalStore: {
// SET … NX is the atomic step; a null reply means the id was already there
add: (id, expiresAt) =>
redis.set(`wl:appr:${id}`, "1", { NX: true, PXAT: expiresAt }).then((r) => r !== null),
},
});

The table below describes the remaining details of approval tokens and the store.

BehaviorStatusNotes
Secret precedenceapproval_secret → $WATCHLIGHT_APPROVAL_SECRET → the signing secret → random per-process keyOne value configures both kinds of token. Both take an ordered list — see the signing secret.
Key derivationNever the secret itselfThe approval key is HMAC-SHA256(secret, "watchlight-de:approval-token:v1"), so an approval token and a scope token cannot be replayed as each other.
Weak secretApprovalError("weak_secret") at constructionMinimum 16 bytes. The error never echoes the secret.
Signed payloadLength-prefixed and versioned<utf8 length>:<field> over the version marker, principal, action, resource, exp and nonce. No two triples can sign the same bytes. Both lanes sign identical bytes, so either verifies the other's token.
Store contractOne atomic check-and-set: add(id, expires_at) -> boolReserve id only if absent; return true when the reservation was new. id is <exp>.<nonce>, never the signature, so leaked rows yield no usable approval. expires_at is epoch milliseconds, so the row can carry a TTL. Synchronous in both lanes; TypeScript also accepts a promise.
Why atomicA read plus a write cannot enforce single useConcurrent consumes across threads, tasks and replicas all reach the store at once, so a read-then-write approves every one of them. In SQL: an insert that fails on a duplicate key. In Redis: SET key value NX PXAT <expires_at>.
Store failureRefuses the approvalfalse, a raise, a non-boolean return, or (TypeScript) outrunning DEFAULT_APPROVAL_STORE_TIMEOUT_MS (2 s) all refuse. Python relies on the client's own timeout. One warning on stderr, then silence.
Every refusalThe same holdExpired, tampered, signed with another key, already consumed, or unanswerable all surface as NeedsApproval with the uniform approval required reason.
EnterpriseKMS-signed, in signed lineageThe DE token is a local HMAC; Enterprise mints them KMS-signed into the fleet-wide execution graph.
Who expires a reservationYou do — the SDK never deletes oneadd is its only write. An expired token is refused before the store is consulted, so a row past its deadline can never admit anything. A store that keeps every row is correct, merely unbounded.
Optional prune(before)Opportunistic, and never moves a verdictCalled after a reservation, at most once a minute per governor, never concurrently, and not at all when add failed. The cutoff lags now by a minute, so a row another replica still needs is never dropped early. A failing prune changes nothing.

Pattern: destructive actions.

Audit sink​

Every decision, sanitization, screening, egress and attenuation record is appended, value-free, to .watchlight/audit.jsonl. On an ephemeral host that file is gone on the next deploy, so the governor takes an additive sink — the same records, handed to your code:

from watchlight import Watchlight
govern = Watchlight(agent="my-agent", audit_sink=lambda record: store.insert(record))
import { Watchlight } from "@watchlight/sdk";
const govern = new Watchlight({ agent: "my-agent", auditSink: (record) => store.insert(record) });

Keeping it off the request path​

The sink is called inside the decision, before your tool body returns, so anything durable is too slow to sit there. Configure batching and a background worker hands the sink a list instead:

Watchlight(agent="my-agent", audit_sink=insert_many, audit_sink_batch=100, audit_sink_interval=2.0)
new Watchlight({ agent: "my-agent", auditSink: insertMany, auditSinkBatch: 100, auditSinkInterval: 2000 });

With batching configured, the sink behaves as follows:

BehaviorStatusNotes
What the sink receivesA list, not a recordSetting either option changes the shape, so a sink written for one record has to be adapted. TypeScript names it BatchAuditSink.
The cost to a decisionA queue put50 decisions against a 50ms sink cost 3ms rather than 2.5 seconds.
Under pressureBounded, oldest droppedA destination that stops responding must not become unbounded memory growth in the application it is auditing. The drop is reported once and trail.dropped counts them — non-zero means the trail has holes, and where they are is not recoverable.
On exitFlushedQueued records are delivered when the process ends normally; call flush() before a deliberate shutdown to wait for them.
A sink that raisesLogged once, worker survivesOtherwise the trail would stop for the life of the process.

These rules apply to every sink, batched or not:

BehaviorStatusNotes
Record shapeIdentical to the file lineIts own copy, with exactly the fields the audit.jsonl line carries: a decision (event: "decision", ts, agent, principal, intent, resource, decision, decision_id?, approved?), a sanitization or screening (counts, mode, decision_id?), an egress, or an attenuation (node_id, parent_id, tools, depth, reason?, and actor_chain when the scope is for a named sub-agent, whose resource then reads scope for <name>). Never argument values. Frozen in TypeScript.
CoverageEvery recordIncluding the attenuations of every scope derived via govern.scope().
The local fileOn by defaultWritten first; nothing the sink does can alter it. audit_file=False / auditFile: false makes the sink the sole destination — no .watchlight directory, and counters() raises instead of counting zero. With neither a file nor a sink the SDK warns once.
The default governorConfigurable until its first recordconfigure_default(...) / configureDefault({...}) gives the exported govern a name, an audit_dir, a sink or a secret before its first governed call, and raises afterwards. An unconfigured default warns once that it has no sink.
SharingOne file per directoryEvery governor pointed at the same audit_dir appends to the same audit.jsonl. Those records interleave and are told apart only by their fields.
BlockingA returned promise is never awaitedThe sink is called synchronously after the append, so a synchronous sink's running time is part of every decision. A promise or awaitable it returns is not awaited: Node leaves it to settle, Python schedules it on the running loop. authorize returns the same verdict whether the sink works or fails, and a returned promise that never settles does not delay it.
FailureIsolated, reported onceA throw is caught and one warning names the error type, never the record. Further failures are silent and the file keeps being written. Delivery is best-effort, so enqueue in the sink and drain with retries if you need every record acknowledged.

Reference sinks — a Postgres row, an OTLP log record, a webhook — are in the audit-sink pattern.

The record kinds are typed​

There are five kinds, discriminated by event: "decision" on a decision and the kind's name on every other one. A decision written before 0.13.0 has no event, so read a missing one as a decision — counters() reads both, which is how it counts decisions and nothing else.

TypeScript exports them as a union, so a sink narrows first and a field read off the wrong shape stops the build:

import { type AuditRecord } from "@watchlight/sdk";

const auditSink = (r: AuditRecord) => {
switch (r.event) {
case "decision":
case undefined: return db.decision(r.principal, r.decision, r.decision_id); // undefined: written before 0.13.0
case "sanitization": return db.redaction(r.counts, r.total);
case "screening": return db.screening(r.counts, r.flagged);
case "egress": return db.egress(r.replaced, r.withheld === true);
case "attenuation": return db.scopeNode(r.node_id, r.parent_id, r.tools);
}
};

Python exports the same five as TypedDicts — DecisionRecord, SanitizationRecord, ScreeningRecord, EgressRecord, AttenuationRecord — with AuditRecord as their union. One honest lane difference: event is optional on DecisionRecord, for trails written before 0.13.0, so a checker cannot narrow the decision case for you. Test it at runtime with record.get("event", "decision") and cast. The distribution carries no py.typed marker, so pyright resolves these from source while mypy treats the package as untyped.

UnknownAuditRecord, or a plain Record<string, unknown> / dict, still satisfies the sink type, so an existing untyped sink keeps working.

Counters​

The trail is also an input. Cedar is stateless, so a quota policy — permit(...) when { context.reads_this_hour < 100 } — needs a number. govern.counters(...) folds the local audit.jsonl into it, right before the decision it feeds:

c = govern.counters(principal='User::"u1"', intent="read", window="1h")
govern.authorize(action="read", principal='User::"u1"', context={"reads_this_hour": c["count"]})
const c = govern.counters({ principal: 'User::"u1"', intent: "read", window: "1h" });  // synchronous
await govern.authorize({ action: "read", principal: 'User::"u1"', context: { reads_this_hour: c.count } });

Both forms return { count, principal, intent, resource, outcome, window: { seconds, start, end }, records, skipped, truncated, source }. Counting works the same way in both languages, as described below:

BehaviorStatusNotes
What countsDecision records onlyA line with a string decision whose event is "decision" or absent (written before 0.13.0). Every other record kind is read past and never counted.
outcomeallowed (default) / denied / allallowed = decision == "Allow", approved ones included. denied = every decision that did not let the body run, so Deny and NeedsApproval. allowed + denied == all.
MatchingExact string equalityprincipal is required and written as it appears on the record (User::"u1"). intent and resource narrow when given. No prefixes, no globs. A record without a principal matches no one.
Window(now − window, now]Start exclusive, end inclusive, on the record's own ts, never on file order. window is "15m", "1h", "24h", "7d", or seconds as a string or integer, positive and at most 366 days; anything else raises. now can be pinned to an ISO-8601 string, an aware datetime or a Date / epoch ms. Clock skew between the writers is yours to manage.
Off-by-oneCounts decisions before this oneThe first call sees 0, so < 100 admits exactly 100 and denies the 101st.
Sourcelocal (default) or externallocal folds this container's audit.jsonl. An audit sink is never read back, so on an ephemeral host the count restarts with the file. Configure a counter source to fold your durable store instead.
Bounded readStreamed, at most max_bytes / maxBytes (64 MiB)Taken from the end of the file. When the file is larger, truncated is true and count is a lower bound — omit the counter so the policy denies, raise the bound, or rotate the file. A line over 1 MiB or nested past 32 levels is skipped unparsed.
CostA rescan per callNo index, no cache. Sub-millisecond on a small trail, hundreds of milliseconds at tens of MiB. Rotate on a schedule longer than your widest window, or lower the bound.
Trust boundaryThe file's writersAnyone who can write the audit file can reset their own quota, and concurrent writers make the count approximate.
Malformed linesSkipped, counted in skippedInvalid JSON, no string decision, an unparseable ts, a leading BOM, or over the line or nesting cap. Nothing about the line is echoed.
Missing fileZero countsA fresh agent has done nothing yet; not an error.
Unreadable fileAuditTrailUnreadableExists but cannot be read. The path is on the error object (.path), not in the message.

The pure form — count_audit_records(path, ...) / countAuditRecords(path, {...}) — counts any audit file. Pattern: quotas.

Counter source​

A counter source is the read side of the audit sink. The sink writes the records and the source answers the same question over them, so a quota spans every replica and survives a deploy.

govern = Watchlight(
audit_sink=lambda record: db.insert("agent_audit", record),
counter_source=lambda q: db.count_decisions(
decisions_only=True, # event = 'decision', or absent before 0.13.0 — see the table below
principal=q["principal"], intent=q.get("intent"), resource=q.get("resource"),
outcome=q["outcome"], after=q["window"]["start"], until=q["window"]["end"],
),
)
-- the same query over the jsonb column of the audit-sink pattern
select count(*) from agent_audit
where coalesce(record->>'event', 'decision') = 'decision' -- decisions only, before and after 0.13.0
and record->>'principal' = $1
and record->>'decision' = 'Allow' -- outcome = "allowed"
and ts > $2 and ts <= $3; -- start exclusive, end inclusive
const govern = new Watchlight({
auditSink: (record) => db.insert("agent_audit", record),
counterSource: (q) => db.countDecisions(q),
});

A counter source must follow these rules:

BehaviorStatusNotes
The queryValidated and resolved{principal, outcome, window: {seconds, start, end}}, plus intent / resource only when the caller filtered on them — omitted, never None, so one shared counting service sees one shape. window.start is exclusive, window.end inclusive, both ISO-8601 UTC, so it maps straight onto a range query. Validation runs before the source is called.
What it must countDecision rows onlyThe same rules as the local scan. A sanitization or screening record can carry a principal of its own, so a query filtered on principal and window alone over-counts and the quota denies early. Filter on event being "decision" or absent: since 0.13.0 a decision carries "event": "decision", so a filter on the absence of event alone counts none of the new ones.
The returnA non-negative integerAt most 2^53 − 1, identical in both lanes. A float, a string, None or a negative raises CounterSourceError.
FailureFail-closedA raise becomes CounterSourceError, with a value-free message and the original as __cause__ / cause. It never falls back to the local file. Handle it in the context binding the way you handle truncated: omit the counter, and the policy's Deny is audited.
The resultsource: "external"records and skipped describe the local scan that did not happen and are 0; truncated is false.
Async sourcescounters_async / countersAsyncA source may return a promise; read it with the async method. The synchronous counters() raises, naming the async one, rather than answering from the local file.
No sourceNothing changesThe local file is folded as above and source is "local".

Which forms compose​

A context binding on a governed tool may be synchronous or async. An async binding is awaited before the decision, so a count that needs a round trip to your store is part of the context the policy evaluates. The table shows what happens for each combination of counter source and binding:

Counter sourcecontext bindingWhat happens
none — the local filesynchronousthe local count feeds the policy
none — the local fileasyncthe same count, awaited before the decision
synchronous sourcesynchronousthe store's count feeds the policy
synchronous sourceasyncthe store's count, awaited before the decision
async sourcesynchronousCounterSourceError, naming counters_async / countersAsync — never a quiet fall back to the local file
async sourceasyncthe store's count feeds the policy, through tool()
async def quota(o):
c = await govern.counters_async(principal=user(o), intent="read", window="1h")
return {} if c["truncated"] else {"reads_this_hour": c["count"]}

# An async binding needs an `async def` body: the decision is made before the
# body runs, so a synchronous tool cannot await one and raises `TypeError`.
@govern.tool("read", principal=user, context=quota)
async def fetch_document(o): ...

The async form belongs to the tool binding. authorize takes a context you have already resolved. Handing it an unresolved promise raises UnresolvedContextError before anything reaches the engine and writes no decision record — a programming error is not a decision. Resolve first:

counts = await govern.counters_async(principal=user_id, intent="read", window="1h")
govern.authorize(action="read", resource="doc", principal=user_id,
context={"reads_this_hour": counts["count"]})
const counts = await govern.countersAsync({ principal: userId, intent: "read", window: "1h" });
await govern.authorize({ action: "read", resource: "doc", principal: userId,
context: { reads_this_hour: counts.count } });

The binding is resolved once per call, before the decision and before the re-decision an approval triggers, so both see the same attributes.

How far the bound reaches. A counter bounds one principal over a window, inside the deployment that writes the records and reads them back. The same bound evaluated centrally across every agent and environment is a goal, which lives in the governed control plane (Enterprise).

Performance​

The engine runs in-process, so an authorization is a function call rather than a network round-trip. A decision is tens of microseconds, which is what makes it practical in front of every tool call.

The figures below were measured on an Apple M4 Max, single-threaded and warm, with the three-policy money-movement set from testing. Each figure is the pure decision core, excluding the audit-file write and your tool's own work:

Python (watchlight-engine, native wheel)Node (@watchlight/engine, WASM)
authorize p50~33 µs~41 µs
authorize p99~44 µs~55 µs
Throughput (1 core)~30,000 / s~24,000 / s
Cold start (first decision)~9 ms~47 ms
Add / hot-reload a policy (p50)~63 µs~91 µs

Worth knowing:

  • The numbers grow with your policy set and the size of the context. Measure your own set with a warm loop, and gate it with the test harness so that a policy change cannot slow decisions down or change a verdict without you noticing.
  • Cold start happens once, when the engine is constructed. Starting Node's WASM engine costs more than loading Python's native wheel, but both happen only once per process.
  • Policies can be added to the running engine with govern.allow(...). No restart is needed, and each one takes tens of microseconds.
  • The figures leave out the audit write, but your decisions do not. A synchronous audit sink runs inside the decision, so its whole running time is added to every decision. Only an awaitable that the sink returns is scheduled without being awaited. Keep the sink's body cheap and hand the record to a queue, or configure batching, which turns the cost into a queue put.

See also​