Policies¶
A policy is a runtime-attached rule that gates changes to the graph
before they land. A behavior proposing a graph mutation under a
policy doesn't get a direct apply — the change becomes an
approval in proposed state. An operator (or an auto-approve
setting) then approves the proposal, and the change lands.
Policies are how the framework lets an operator sit in the loop without rewriting the behavior. The behavior says "I want to add this memo"; the policy says "memos require explicit approval"; the operator says "yes, approve it." The same behavior runs in dev (auto-approve) and prod (explicit-approve) without code changes.
What gets gated¶
Two operations can be policy-gated:
- Object proposals via
ctx.propose_object(type, data, reason). Instead of an immediateadd_object, the framework creates a pending approval and emitsapproval.proposed. The object lands only when the approval is granted. - Patches. A patch declared as policy-gated takes the same
proposed-and-approved path, except the patch lifecycle lives in
the patch event types (
patch.proposed/patch.applied) rather than approval event types. Seepatchesfor the patch state machine.
Object proposals are the more common shape. The diligence pack's
memo_approval and risk_approval policies are the canonical
examples: the pack declares which object types require approval,
the operator decides per-instance.
Not every change is policy-gated. Direct graph.add_object,
graph.patch_object, and graph.emit calls land immediately;
they're for changes the behavior author decided don't need
operator review. The behavior chooses by calling the proposal
method instead of the direct method.
The approval lifecycle¶
Both transitions are one-shot. A proposed approval becomes granted
exactly once (via runtime.approve(id, approved_by=...)) or
denied exactly once (via runtime.deny(id, denied_by=..., reason=...)).
Calling either on an already-terminal approval raises
approval-not-found-error
— the approval id is consumed by the transition.
Each transition emits an event:
approval.proposed— carries the proposal kind (object/patch), the type, the data, the reason from the proposing behavior, and the pack that owns the gating policy.approval.granted— carries the approval id, the approver identity, and the resulting object id (or applied patch id).approval.denied— carries the approval id, the denier identity, and the denial reason.
The events sit in the log alongside everything else. Downstream behaviors can subscribe to them; replay reconstructs the full proposal-and-decision sequence; the trace renders them.
Declaring policies¶
Packs declare policies as part of their Pack(...) declaration:
from activegraph.packs import Pack, PackPolicy
pack = Pack(
name="diligence",
version="0.1.0",
policies=[
PackPolicy(
name="memo_approval",
requires_approval=["memo"],
settings_key="auto_approve_memos",
),
...
],
...
)
requires_approval lists the object types the policy gates.
settings_key names the pack-settings boolean that controls
auto-approve behavior; when True, the framework approves every
proposal automatically and the behavior runs as if the policy
weren't there. When False, every proposal pauses until an
operator decides.
The pack ships with the policies; the runtime instance decides
auto-approve via its DiligenceSettings(auto_approve_memos=...).
That separation lets one pack run in different approval modes
across environments.
How a behavior proposes¶
A behavior that wants its change to flow through a policy calls
ctx.propose_object instead of graph.add_object:
@behavior(name="memo_synthesizer", on=["claim.completed"])
def memo_synthesizer(event, graph, ctx):
...
ctx.propose_object(
"memo",
data={"title": "Diligence memo", "body": "..."},
reason="diligence run complete",
)
The propose call returns an approval id. The runtime decides
whether to apply immediately (auto-approve setting is True) or
queue the proposal (setting is False). Either way, the behavior
body completes; the approval lifecycle continues independently.
If the behavior tries ctx.propose_object outside a
runtime-bound context — typically a test fixture or a refactored
helper — it raises
runtime-context-required-error.
The operator-facing recovery¶
When auto-approve is off, the operator drives the lifecycle:
for pa in rt.pending_approvals():
print(pa.id, pa.kind, pa.object_type, pa.reason)
# Approve one:
rt.approve(approval_id, approved_by="operator-jane")
# Or deny:
rt.deny(approval_id, denied_by="operator-jane", reason="not yet")
The CLI surface for production approval workflows is in the operating guide.
The events-not-exceptions principle applied¶
A denied approval is an event (approval.denied), not an
exception. A behavior whose proposal gets denied doesn't see a
raised exception — it sees the denial in the event log if it
subscribes to approval.denied. The runtime continues; the
behavior author writes a retry-or-escalate behavior if denial
needs a response.
The exception case is misuse of the primitive — passing a
nonexistent approval id to approve / deny — which fires
ApprovalNotFoundError. See
failure-model for the broader principle.
What's related¶
patches— the durable-change primitive that policies gate. Approvals and patches share the proposed-and- decided shape; patches are the lower-level primitive.behaviors— wherectx.propose_objectis called from.failure-model— why denials are events.approval-not-found-error— the exception for misuse of the approval API.- Operating in production — production workflows for the operator side.