agent_lifecycle.rego
Governs the two lifecycle events: an agent creating a child agent (agent.spawn), and an agent handing a task to another agent (agent.delegate). The action layer for those — what the resulting child can do — lives in delegation.rego.
Package: kitelogik.agent_lifecycle · Source: kitelogik/policies/agent_lifecycle.rego
Rules
agent.spawn
| Outcome | Trigger |
|---|---|
| Allow | delegation_depth ≤ 2 (and is a number) and every requested capability is in the parent's session_scopes |
| Deny | delegation_depth > 2 |
| Deny | delegation_depth missing, null, or non-numeric |
| Deny | A non-empty requested_capabilities list contains any scope not in the parent's session_scopes |
agent.delegate
| Outcome | Trigger |
|---|---|
| Allow | delegation_depth ≤ 1 (and is a number) and every requested scope is in the parent's session_scopes |
| Deny | delegation_depth > 1 |
| Deny | delegation_depth missing, null, or non-numeric |
| Deny | A non-empty requested_capabilities list contains any scope not in the parent's session_scopes |
The depth ceilings differ by event type — agent.spawn allows up to depth 2 (the parent can produce a child, which can produce a grandchild), but agent.delegate caps at depth 1 (only direct hand-offs from the original session).
Why the is_number(...) guards matter
OPA structural ordering treats null < number, so null <= 2 evaluates to true — meaning a missing delegation_depth would silently allow spawn at any depth. Both allow rules wrap the comparison in is_number(...) and there are dedicated deny rules for malformed events:
# Malformed event — hard-deny rather than fall through
deny if {
input.event_type == "agent.spawn"
not is_number(object.get(input.context, "delegation_depth", null))
}object.get(..., null) ensures the value is always bound, so not is_number(...) also catches the missing-key case.
Capability and scope subset rules
The _capabilities_valid and _delegate_scopes_valid helpers both require every requested capability to be in the parent's session scopes:
_capabilities_valid if {
every cap in input.requested_capabilities {
cap in input.context.session_scopes
}
}This enforces scope-narrowing — a child can never gain a scope the parent didn't already have. The parent can still grant fewer scopes (narrowing) without changing this policy.
How main.rego routes events to this module
# Route agent.spawn and agent.delegate to agent_lifecycle policy
allow if {
not deny
input.event_type in {"agent.spawn", "agent.delegate"}
agent_lifecycle.allow
}
deny if {
input.event_type in {"agent.spawn", "agent.delegate"}
agent_lifecycle.deny
}So agent_lifecycle.rego is the canonical lifecycle gate — its allow flows up directly, its deny escalates to the aggregator.
Extending in your own project
Same-package extension:
package kitelogik.agent_lifecycle
import future.keywords.if
# Restrict spawn to specific user roles
deny if {
input.event_type == "agent.spawn"
not input.context.user_role in {"orchestrator", "supervisor"}
}
# Cap spawn rate per session (requires writing context.spawns_in_session
# from your runtime — not part of the OSS context shape)
deny if {
input.event_type == "agent.spawn"
input.context.spawns_in_session > 5
}