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.
| Field | Status | Notes |
|---|---|---|
principal | Enforced | A 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. |
action | Enforced | Matched as Action::"<value>". |
resource | Enforced | Matched as Resource::"<value>". |
context.<key> | Enforced | Read by Cedar when / unless. A missing key referenced by a forbid denies — a deliberate deviation from raw Cedar. |
context.actor | Reserved · Enforced | The 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_chain | Reserved · Enforced | The 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.agent | Not usable | The 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_score | Platform | Behavioral drift score, supplied by the engine and unforgeable. Stubbed at 0 in DE. |
context.agent.quarantined | Platform | Set by the platform's quarantine pipeline; always false in DE. |
intent.category | Enforced | Drives the intent rules below. |
intent.risk_level | Enforced | critical escalates (below). |
intent.objective / justification | Enforced | Some 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.
| Intent | Behavior |
|---|---|
financial_operation | Escalates to human approval (Deny + escalation_required) |
risk_level: critical | Escalates to human approval |
system_administration | Denied unless a justification is supplied |
security_compliance | Denied 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:
| Field | Status | Notes |
|---|---|---|
allowed_tools | Enforced | Child must be a subset of the parent's. |
allowed_intents | Enforced | Child must be a subset. |
allowed_resources | Enforced | Subset by exact string; see the matcher note below. |
max_depth | Enforced | A decrementing budget: a child's max_depth must be ≤ parent − 1; 0 means the scope can no longer delegate. |
time_budget_seconds | Enforced | Clamped to the parent's remaining budget and deadline. |
depth | Advisory | Carried 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. |
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.
| Check | Status | Notes |
|---|---|---|
| Secret | Required | signing_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. |
| Length | Enforced | Tokens over 16 384 characters are rejected unparsed. |
| Version | Enforced | Only wls1; the prefix is part of the signed bytes. |
| Encoding | Enforced | Strict base64url, no padding, canonical; the payload must be canonical JSON (sorted keys, no whitespace, set arrays sorted by code point). |
| Signature | Enforced | Constant-time compare, checked before the payload is parsed. |
| Schema | Enforced | Exactly 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. |
| Identity | Enforced | agent must equal the verifying governor's agent. |
| Time | Enforced | iat 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. |
| Subset | Enforced by the engine | The 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). |
| Expiry | Enforced | A 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 outcome | Status | Effect |
|---|---|---|
| returns a value | Enforced | The value replaces the payload the caller / model receives. |
returns None / undefined / null | Enforced | The payload passes through unchanged. |
| raises / throws | Enforced (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_id | Enforced | The 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.obligations | Enforced | The 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.
| Adapter | Intent | Subject | Resource | Cedar context | Human-in-the-loop | Egress |
|---|---|---|---|---|---|---|
govern.tool() / @govern.tool | intent | principal | resource | context | onNeedsApproval / on_needs_approval | onResult / on_result |
governTool(tool, …) (TS) | intent | principal | resource | context | onNeedsApproval | onResult |
governTools(tools, …) (TS) | intentFor(name) | principal | resourceFor(name) | context | onNeedsApproval | onResult |
governedHooks(…) (TS) | intentFor(name) | principal | resourceFor(call) | context | onNeedsApproval | onResult, onResultTimeoutMs |
governed_plugin() (Python) | per call, authorize_action(action, …) | per call, authorize_action(…, principal=…) — needs watchlight-agent-sdk ≥ 0.7.0 | per 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.
| Annotation | Python | TypeScript | Status | Notes |
|---|---|---|---|---|
@obligate_redact("ssn, dob") | redact: ["ssn","dob"] | redact | Enforced by you | Field 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: 25 | maxItems | Enforced by you | Upper bound on how many items the caller may act on or return. Integer 1..=4294967295. |
@obligate_log_values("false") | log_values: false | logValues | Enforced by you | Whether the values handled under this decision may be logged. Exactly true / false. |
@obligate_<name>("raw") | extra: {name: ["raw"]} | extra | Advisory | Any 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.
redactbecomes the union of all the lists,max_itemsthe smallest value, andlog_valuesistrueonly if every policy saystrue.extra[name]keeps the sorted distinct values from all of them. - An obligation that cannot be read blocks the call. If
max_itemsis outside1..=4294967295,log_valuesis not a boolean, orredactis not a non-empty list of non-blank strings, the SDK raisesAuthorizeErrorand 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.testfixture 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.
| Exception | Base | Raised when | Attributes |
|---|---|---|---|
Denied | PermissionError | A @govern.tool call is refused (fail-closed: no matching permit, or an explicit deny). | .tool, .intent, .reason |
AttenuationDenied | PermissionError | Scope.attenuate(...) requests authority the parent doesn't hold (not a strict subset). | .violations (e.g. ['AllowedTools']), .reason |
DelegationDepthExceeded | AttenuationDenied | Scope.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']) |
PolicyCompileError | RuntimeError | allow, 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 |
ScopeTokenError | PermissionError | Scope.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:
| Default | What it means | Configure | |
|---|---|---|---|
| Signing key | A random key minted at import | A token cannot cross a process boundary, and a restart invalidates every outstanding approval | approval_secret= / approvalSecret |
| Single use | An in-process map of reserved ids | Atomic 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 token | approval_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.
| Behavior | Status | Notes |
|---|---|---|
| Secret precedence | approval_secret → $WATCHLIGHT_APPROVAL_SECRET → the signing secret → random per-process key | One value configures both kinds of token. Both take an ordered list — see the signing secret. |
| Key derivation | Never the secret itself | The 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 secret | ApprovalError("weak_secret") at construction | Minimum 16 bytes. The error never echoes the secret. |
| Signed payload | Length-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 contract | One atomic check-and-set: add(id, expires_at) -> bool | Reserve 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 atomic | A read plus a write cannot enforce single use | Concurrent 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 failure | Refuses the approval | false, 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 refusal | The same hold | Expired, tampered, signed with another key, already consumed, or unanswerable all surface as NeedsApproval with the uniform approval required reason. |
| Enterprise | KMS-signed, in signed lineage | The DE token is a local HMAC; Enterprise mints them KMS-signed into the fleet-wide execution graph. |
| Who expires a reservation | You do — the SDK never deletes one | add 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 verdict | Called 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:
| Behavior | Status | Notes |
|---|---|---|
| What the sink receives | A list, not a record | Setting either option changes the shape, so a sink written for one record has to be adapted. TypeScript names it BatchAuditSink. |
| The cost to a decision | A queue put | 50 decisions against a 50ms sink cost 3ms rather than 2.5 seconds. |
| Under pressure | Bounded, oldest dropped | A 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 exit | Flushed | Queued records are delivered when the process ends normally; call flush() before a deliberate shutdown to wait for them. |
| A sink that raises | Logged once, worker survives | Otherwise the trail would stop for the life of the process. |
These rules apply to every sink, batched or not:
| Behavior | Status | Notes |
|---|---|---|
| Record shape | Identical to the file line | Its 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. |
| Coverage | Every record | Including the attenuations of every scope derived via govern.scope(). |
| The local file | On by default | Written 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 governor | Configurable until its first record | configure_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. |
| Sharing | One file per directory | Every governor pointed at the same audit_dir appends to the same audit.jsonl. Those records interleave and are told apart only by their fields. |
| Blocking | A returned promise is never awaited | The 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. |
| Failure | Isolated, reported once | A 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:
| Behavior | Status | Notes |
|---|---|---|
| What counts | Decision records only | A 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. |
outcome | allowed (default) / denied / all | allowed = decision == "Allow", approved ones included. denied = every decision that did not let the body run, so Deny and NeedsApproval. allowed + denied == all. |
| Matching | Exact string equality | principal 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-one | Counts decisions before this one | The first call sees 0, so < 100 admits exactly 100 and denies the 101st. |
| Source | local (default) or external | local 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 read | Streamed, 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. |
| Cost | A rescan per call | No 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 boundary | The file's writers | Anyone who can write the audit file can reset their own quota, and concurrent writers make the count approximate. |
| Malformed lines | Skipped, counted in skipped | Invalid JSON, no string decision, an unparseable ts, a leading BOM, or over the line or nesting cap. Nothing about the line is echoed. |
| Missing file | Zero counts | A fresh agent has done nothing yet; not an error. |
| Unreadable file | AuditTrailUnreadable | Exists 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:
| Behavior | Status | Notes |
|---|---|---|
| The query | Validated 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 count | Decision rows only | The 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 return | A non-negative integer | At most 2^53 − 1, identical in both lanes. A float, a string, None or a negative raises CounterSourceError. |
| Failure | Fail-closed | A 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 result | source: "external" | records and skipped describe the local scan that did not happen and are 0; truncated is false. |
| Async sources | counters_async / countersAsync | A 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 source | Nothing changes | The 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 source | context binding | What happens |
|---|---|---|
| none — the local file | synchronous | the local count feeds the policy |
| none — the local file | async | the same count, awaited before the decision |
| synchronous source | synchronous | the store's count feeds the policy |
| synchronous source | async | the store's count, awaited before the decision |
| async source | synchronous | CounterSourceError, naming counters_async / countersAsync — never a quiet fall back to the local file |
| async source | async | the 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
- How policy works · Examples — the Cedar layer
- Testing & rolling out policies — golden fixtures and a CI gate
- Enforcement effects —
@enforcement_effect(...)and HITL - Breaking changes — read before you bump the version