Skip to main content

Signal Governance Policy

The signal-governance policy category controls which signals can be dispatched, from what sources, with what rate-limits, and what payload constraints. It evaluates at signal ingestion time -- before any agent execution begins.

Use it to lock down which external triggers your tenant accepts (webhooks, schedules, agent-emitted signals), throttle floods, and require correlation IDs for auditability.

Rules

RuleTypeDefaultDescription
allowed_signalsstring[][]Signal names allowed. Empty = all allowed
blocked_signalsstring[][]Signal names blocked
allowed_sourcesstring[][]Source types allowed (webhook, agent, schedule, manual, api)
blocked_sourcesstring[][]Source types blocked
rate_limit_per_minuteinteger0Max signals/min per name. 0 = unlimited
rate_limit_per_hourinteger0Max signals/hour per name. 0 = unlimited
require_correlation_idbooleanfalseRequire _correlation_id in payload
max_payload_size_kbinteger0Payload size cap in KB. 0 = unlimited
log_all_signalsbooleantrueLog every ALLOW
action_on_violationstringblockblock or warn

How It Works

The handler is runtime-plane only -- the observe plane never sees signal dispatch events.

Primary enforcement at before_signal_dispatch(signal_name, payload). before_workflow and after_workflow are no-ops (audit-only stubs).

Order of checks:

  1. Blocked signals -- signal_name in blocked_signals -> violation
  2. Allowed signals -- if allowed_signals non-empty and signal not in it -> violation
  3. Blocked sources -- source_type in blocked_sources -> violation
  4. Allowed sources -- if allowed_sources non-empty and source not in it -> violation
  5. Payload size -- len(json.dumps(payload)) vs max_payload_size_kb * 1024
  6. Correlation ID -- if require_correlation_id, payload must include _correlation_id
  7. Rate limit per minute -- in-memory sliding window per (tenant, signal_name)
  8. Rate limit per hour -- same window, hour bucket

Context Attributes Read

AttributePhasePurpose
context.source_typebefore_signal_dispatchSource type for the dispatch
payload._source_typebefore_signal_dispatchFallback source resolver
payload._correlation_idbefore_signal_dispatchFor require_correlation_id check
context.tenant_idbefore_signal_dispatchScope rate-limit windows

Rate Limiting

Uses an in-memory dict _rate_windows keyed by f"{tenant_id}:{signal_name}". Each entry is a list of monotonic timestamps; cleaned to 1-hour cutoff on every dispatch.

Example Policy

Lock down a tenant's signal surface

{
"name": "Production signal lock-down",
"category": "signal-governance",
"rules": {
"allowed_signals": ["research_vendor", "analyze_contracts", "summarize"],
"blocked_signals": ["admin_override", "delete_all"],
"allowed_sources": ["webhook", "schedule"],
"rate_limit_per_minute": 60,
"rate_limit_per_hour": 500,
"require_correlation_id": true,
"max_payload_size_kb": 512,
"log_all_signals": true,
"action_on_violation": "block"
},
"scope": {
"agents": ["*"]
},
"enabled": true
}

SDK Integration

import waxell_observe as waxell
waxell.init()

# Signals are dispatched via the runtime's signal-dispatch API.
# The runtime calls before_signal_dispatch(signal_name, payload)
# automatically -- no SDK-level changes needed.

# To pass a correlation ID:
await runtime.dispatch_signal(
"research_vendor",
payload={"_correlation_id": "req-abc-123", "vendor": "Acme"},
)

Observability

FieldExample (BLOCK -- not allowed)
Categorysignal-governance
Actionblock
ReasonSignal 'admin_override' is blocked by policy
Metadata{"signal": "admin_override"}
FieldExample (BLOCK -- rate limit)
Actionblock
ReasonSignal 'webhook_trigger' rate limit exceeded (61/60 per minute)
Metadata{"signal": "webhook_trigger", "rate": 61, "limit": 60, "window": "minute"}
FieldExample (BLOCK -- payload size)
Actionblock
ReasonSignal payload exceeds limit (612.4KB > 512KB)

Common Gotchas

  • Runtime-plane only. The observe plane never sees signal dispatch -- supported_planes = ["runtime"]. If you're running observe-only, this category does nothing.
  • In-memory rate windows. _rate_windows is a class-level dict -- not durable across process restarts and not shared across workers. For strict global limits, use the Rate Limit Policy which has Redis-backed state.
  • Empty allowed_signals = ALL signals allowed. Only acts as an allowlist when non-empty. Same shape as the allowlist family.
  • Source type resolution. First tries context.source_type, then payload["_source_type"]. Empty source bypasses source checks entirely.
  • Payload size uses json.dumps with default=str. Some non-serializable objects are stringified, inflating size.
  • require_correlation_id only checks payload["_correlation_id"]. Not context.correlation_id and not other variant names.
  • First violation short-circuits. Subsequent checks aren't run. If you need to know ALL violations on a signal, run with warn first and inspect the trace.
  • log_all_signals is INFO-level. Set log level on waxell.policies.handlers.signal_governance to silence ALLOW logs in production.

Next Steps