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
| Rule | Type | Default | Description |
|---|---|---|---|
allowed_signals | string[] | [] | Signal names allowed. Empty = all allowed |
blocked_signals | string[] | [] | Signal names blocked |
allowed_sources | string[] | [] | Source types allowed (webhook, agent, schedule, manual, api) |
blocked_sources | string[] | [] | Source types blocked |
rate_limit_per_minute | integer | 0 | Max signals/min per name. 0 = unlimited |
rate_limit_per_hour | integer | 0 | Max signals/hour per name. 0 = unlimited |
require_correlation_id | boolean | false | Require _correlation_id in payload |
max_payload_size_kb | integer | 0 | Payload size cap in KB. 0 = unlimited |
log_all_signals | boolean | true | Log every ALLOW |
action_on_violation | string | block | block 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:
- Blocked signals --
signal_name in blocked_signals-> violation - Allowed signals -- if
allowed_signalsnon-empty and signal not in it -> violation - Blocked sources --
source_type in blocked_sources-> violation - Allowed sources -- if
allowed_sourcesnon-empty and source not in it -> violation - Payload size --
len(json.dumps(payload))vsmax_payload_size_kb * 1024 - Correlation ID -- if
require_correlation_id, payload must include_correlation_id - Rate limit per minute -- in-memory sliding window per
(tenant, signal_name) - Rate limit per hour -- same window, hour bucket
Context Attributes Read
| Attribute | Phase | Purpose |
|---|---|---|
context.source_type | before_signal_dispatch | Source type for the dispatch |
payload._source_type | before_signal_dispatch | Fallback source resolver |
payload._correlation_id | before_signal_dispatch | For require_correlation_id check |
context.tenant_id | before_signal_dispatch | Scope 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
| Field | Example (BLOCK -- not allowed) |
|---|---|
| Category | signal-governance |
| Action | block |
| Reason | Signal 'admin_override' is blocked by policy |
| Metadata | {"signal": "admin_override"} |
| Field | Example (BLOCK -- rate limit) |
|---|---|
| Action | block |
| Reason | Signal 'webhook_trigger' rate limit exceeded (61/60 per minute) |
| Metadata | {"signal": "webhook_trigger", "rate": 61, "limit": 60, "window": "minute"} |
| Field | Example (BLOCK -- payload size) |
|---|---|
| Action | block |
| Reason | Signal 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_windowsis 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, thenpayload["_source_type"]. Empty source bypasses source checks entirely. - Payload size uses
json.dumpswithdefault=str. Some non-serializable objects are stringified, inflating size. require_correlation_idonly checkspayload["_correlation_id"]. Notcontext.correlation_idand 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
warnfirst and inspect the trace. log_all_signalsis INFO-level. Set log level onwaxell.policies.handlers.signal_governanceto silence ALLOW logs in production.
Next Steps
- Rate Limit Policy -- Tenant-wide rate caps (Redis-backed)
- End-User Rate Limit -- Per-sub-user rate caps
- Input Validation Policy -- Payload schema validation
- Audit Policy -- Audit logging for signal dispatches
- Policy Categories & Templates -- All categories