Control-layer architecture¶
Tulip separates deciding what to do from deciding whether a consequential
action may execute. This page is the domain-neutral map. SecurityContext is
one worked application of the same control primitives.
flowchart LR
U[request] --> L[agent loop]
L --> T[tool proposal]
L --> G[optional grounding]
G --> L
T --> C[classify as Action]
C --> P{ControlPolicy}
P -->|allow| E[execute side effect]
P -->|require_human| H[approval store]
P -->|deny| X[do not execute]
H -->|approved + resumed| E
H -->|denied| X
P --> A[(AuditTrail)]
H --> A
E --> A
L <--> S[(checkpointer)]
Responsibilities by layer¶
| Layer | Runtime responsibility | Developer responsibility | Model-dependent part |
|---|---|---|---|
| Agent loop | Runs the think/tool/observe cycle and emits typed events | Set tools, budgets, termination, and provider configuration | Tool choice and generated arguments |
| Grounding | Applies configured thresholds to a typed evidence partition | Enable it, supply relevant evidence, calibrate thresholds, choose fail behavior | Claim extraction and evidence classification |
| Action classification | Carries explicit environment, kind, scope, and tags | Derive complete, accurate labels from the actual call | Avoid using the model as the sole classifier for critical labels |
| Admission | Calls the side effect only on allow; returns/raises on hold or deny |
Route every consequential path through admit() or gate_tool() |
None in ControlPolicy evaluation itself |
| Approval | Persists a pending decision through a configured store | Provide a durable store, authenticated decision channel, and resume flow | None required for the store state machine |
| Persistence | Restores supported run state through a checkpointer | Choose and operate a backend with suitable durability and isolation | None |
| Audit | Hash-chains records supplied to an AuditTrail |
Persist/export the trail and protect its head externally | None |
The enforcement boundary¶
admit(action, perform, ...) owns one concrete boundary: it does not call
perform unless policy returns allow (or a previously held call is resumed
with a valid approval). A deny or hold is recorded when a trail is supplied.
That guarantee applies only to the callable passed as perform. It does not
discover an alternate direct call elsewhere in your application. Prefer
gate_tool() when exposing a side-effecting tool to an agent, keep the
ungated implementation private, and test that every registered mutating tool
is wrapped.
Failure behavior¶
| Failure | Behavior to design for |
|---|---|
| Policy returns hold or deny | Side effect is not called; handle the refusal or interrupt explicitly |
| Process stops while held | Resume requires a durable approval store and checkpointer |
| External API succeeds, process stops before local result is recorded | A replay may occur; pass a stable idempotency key to the external API |
| Audit records are changed | AuditTrail.verify() detects a broken local chain; protect a durable copy or chain head outside the process |
| Action is mislabeled or a route skips the gate | Policy cannot protect what it cannot see; coverage tests and code review are required |
| Grounding judge is wrong | Typed scores remain an assessment, not a truth oracle; evaluate and calibrate the judge for the deployment |
Recommended production composition¶
- Define a typed tool for the side effect and an argument-derived
Action. - Wrap it with
gate_tool()and a reviewedControlPolicy. - Use a durable approval store for human decisions.
- Use a checkpointer for resumable agent state.
- Send a stable operation key to the downstream service.
- Export audit records to durable, access-controlled storage.
- Test allow, hold, deny, restart, duplicate-call, and crash-recovery paths.
See First controlled action for a small offline run, policy authoring for classification coverage, and SecurityContext for a security-domain application.