Skip to content

Templates & Lifecycle

Introduced in v0.3.0, decision templates are named, immutable policy bundles that can be reused across decisions. They reduce duplication and enforce organizational standards.

Templates define the default policy for a category of decisions:

tools.template_create(
name="prod-deploy",
actor=Actor(type="human", id="platform-team"),
min_approvals=2,
allowed_modes=["dry_run", "apply"],
require_adapter_capabilities=["timeout"],
labels=["prod"],
)
PropertyDescription
nameUnique identifier; cannot be reused after creation
min_approvalsDefault approval threshold
allowed_modesWhich execution modes are permitted
require_adapter_capabilitiesCapabilities the router adapter must declare
max_stepsOptional step limit for bounded execution
labelsTags for discovery and filtering

Once created, a template cannot be modified. This is a deliberate design choice: decisions that reference a template have stable, auditable semantics. If you need different settings, create a new template with a new name.

When creating a request, reference a template and optionally override specific fields:

result = tools.request(
goal="Deploy v2.1.0",
actor=actor,
template_name="prod-deploy",
min_approvals=3, # Stricter for this deploy
)

When template_name is specified, any explicit policy parameters (min_approvals, allowed_modes, require_adapter_capabilities, max_steps, labels) act as overrides on top of the template defaults. The original template remains unchanged.

# List all templates
all_templates = tools.template_list()
# Filter by label
prod_templates = tools.template_list(labels=["prod"])

Introduced in v0.4.0, the lifecycle system provides computed state, blocking reasons, and a human-readable timeline for every decision.

The compute_lifecycle function takes a Decision object (which already contains its events and policy from replay) and an optional timeline limit:

from nexus_control import compute_lifecycle
lifecycle = compute_lifecycle(decision)
# Or with a custom timeline limit (default is 20)
lifecycle = compute_lifecycle(decision, timeline_limit=50)

When a decision cannot proceed, the lifecycle returns structured blocking reasons ordered by a triage ladder. Each reason includes a machine-readable code and a human-readable message:

for reason in lifecycle.blocking_reasons:
print(f"{reason.code}: {reason.message}")

Blocking reason codes are stable across versions and ordered by a deterministic triage ladder:

CodePriorityMeaning
NO_POLICY1Decision has no policy attached
ALREADY_EXECUTED2Decision already ran successfully (terminal)
EXECUTION_FAILED3Previous execution failed (terminal)
APPROVAL_EXPIRED4Had enough approvals but some have lapsed
MISSING_APPROVALS5Not enough approvals to meet the policy threshold

The lifecycle includes a chronological timeline of all events, useful for audit displays and debugging:

for entry in lifecycle.timeline:
print(f" {entry.seq} {entry.label}")

Timeline entries include sequence numbers for ordering and truncation support for long histories.

A decision progresses through these states:

DRAFT → PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED
↘ FAILED
StateMeaning
draftCreated but no policy attached yet
pending_approvalPolicy attached, waiting for sufficient approvals
approvedApproval threshold met, ready for execution
executingExecution in progress
completedExecution finished successfully
failedExecution failed

State transitions are driven by events in the append-only log. The current state is always computed by replaying all events from the beginning, never stored as mutable state. Approval state is re-evaluated on every APPROVAL_GRANTED or APPROVAL_REVOKED event, so revoking an approval can move the decision back from approved to pending_approval.