Skip to main content

End-User Suspension Policy

The end-user-suspension policy category blocks execution when the current run's sub-user has WaxellUser.status = 'suspended'. It's the per-end-user analogue of the tenant Kill Switch -- you can suspend a single bad actor without affecting the rest of the tenant.

Runs at before_workflow, mid_execution, and before_domain_call so a suspension flip takes effect within the next turn for long-running workflows.

Rules

RuleTypeDefaultDescription
enabledbooleantrueToggle enforcement on/off
grace_secondsinteger0Reserved -- tolerate recent status changes by N seconds

The handler reads the user's status directly from the WaxellUser row. There is no policy rule to tune which users are suspended -- that's controlled by the user record itself.

How It Works

The handler runs at:

PhaseEffect when suspended
before_workflowBLOCK -- run never starts a turn
mid_executionBLOCK -- halts at next turn boundary
before_domain_callBLOCK -- no outbound call fires
after_workflowALLOW (no-op -- run already halted)

When no sub-user is on the context, the handler returns ALLOW. This category is scoped to sub-user identity only; for tenant-wide bans use the Kill Switch Policy.

Context Attributes Read

AttributePhasePurpose
context.sub_user_identityallResolve sub_user_id
context.user_idallFallback resolver
context.metadata["tenant_id"] / context.tenant_idallScope the user lookup

Data Sources

  • WaxellUser.status -- compared against WaxellUserStatus.SUSPENDED

Example Policy

{
"name": "Block suspended sub-users",
"category": "end-user-suspension",
"rules": {
"enabled": true,
"grace_seconds": 0
},
"scope": {
"agents": ["*"]
},
"enabled": true
}

A single policy like this on agents: ["*"] covers your whole tenant. You don't need to author per-user policies -- suspension state lives on the user row, not the policy.

SDK Integration

import waxell_observe as waxell
waxell.init()

@waxell.observe(
agent_name="customer-support",
user_id="cust-9912",
enforce_policy=True,
)
async def handle(text: str) -> str:
return await respond(text)

To suspend a user:

wax end-users suspend cust-9912
# or via API:
curl -X POST /api/v1/end-users/cust-9912/suspend/

To unsuspend:

wax end-users unsuspend cust-9912

Observability

FieldExample (BLOCK)
Categoryend-user-suspension
Actionblock
ReasonEnd-user 'cust-9912' is suspended on this tenant. Unsuspend via /api/v1/end-users/<id>/unsuspend/ or wax end-users unsuspend <id>.
Metadata{"sub_user_id": "cust-9912", "tenant_id": "acme"}

Common Gotchas

  • No sub-user on context = ALLOW. Tenant-wide bans must use the Kill Switch Policy.
  • Suspension flip takes effect at next turn boundary, not instantly. A long-running step in flight will finish before the next BLOCK fires.
  • Lookup errors fail-open. If the user lookup raises, the handler returns ALLOW with a warning log. Better to let a real run proceed than to take everyone down on a transient DB error.
  • No rule to "suspend user X". Suspension state is on the WaxellUser row; the policy just decides whether to enforce it. Tenants who want suspension behavior on always can leave this category enabled tenant-wide.
  • grace_seconds is reserved. Currently not honored by the handler; future phase will let you tolerate very recent status flips to avoid race conditions on suspend/unsuspend.
  • Observe plane uses Django ORM. Runtime plane wires a cached / Redis lookup via _status_query_fn.

Next Steps