Upgrading to 3.0 Open-Core
3.0 adds decide rules: narrow questions a self-hosted model answers about a tool call. A workspace with no decide: line in its SOPs behaves as it did on 2.9, and its policy snapshot is byte for byte what 2.9 wrote. The changes below apply once a SOP carries one.
Gates older than 3.0 refuse what an enforcing rule covers
A gate that cannot evaluate a decide rule must not let through the calls the rule was written to stop. So for every rule stamped enforce, the policy snapshot carries a fallback line: an ordinary block rule on the rule's tools, with an id starting decide-fallback: and the reason
Decide rule prod-data-export (Production data handling): upgrade to Intutic 3.0 to evaluate this rule- A 2.x gate (hook gates written before gate body v22, and
@intutic/gateorintutic-clawdebefore 3.0) skips the snapshot's new@formatand@decidelines, as it skips any line it does not know, and enforces the fallback line. Every call to the rule's tools is refused, with the refusal codeSNAPSHOT. - A 3.0 gate decodes the rule's
@deciderecord, checks it against its hash, and skips the fallback line only when the record is intact. A damaged record leaves the fallback line in force, so the call is refused. - A rule running as shadow has no fallback line, so a 2.x gate allows those calls as before.
- A
decide:line that does not read gets a fallback line whatever its rollout, and it refuses its tools (every tool, when it names none that can be read) on every gate, 2.x and 3.0 alike, until it is fixed. In a workspace whose intervention mode is silent logging, that line logs instead of refusing.
A rule is stamped enforce only once its rollout is enforce and a passing intutic decide eval has pinned a model for it, so nothing is refused this way on the day you upgrade. When it is, update the gates on each machine that runs the rule: intutic gates install rewrites them on a machine with no login, and a connected machine's sync daemon rewrites them on its next sync. Upgrade @intutic/gate and intutic-clawde to 3.0 in agents that use the gate SDKs.
A 3.0 gate that reads a snapshot format newer than it knows reports snapshot_format_newer and enforces what it can read.
A rule's rollout is rollout, never mode
A decide rule carries its own rollout, "rollout": "shadow" (the default) or "rollout": "enforce", inside its JSON:
decide: {"id":"prod-data-export","on":"tool_call","tools":["Bash"],"questions":{"exports":{"type":"yes_no","ask":"Does this command copy customer data out of the production database?"}},"when":"exports","rollout":"shadow"}- There is no
modefield. A rule is read strictly, so"mode": "shadow"inside the JSON is an unknown field: the line does not read (decide: unknown field mode) and refuses its tools. - A SOP's own
mode: shadowdoes not reach its decide rules. It governs the SOP's other keys. A decide rule runs as shadow because its ownrolloutsays so, or for one of the reasons in When a rule enforces. "rollout": "enforce"is a ceiling, not a switch. The rule enforces only on a model a pre-registeredintutic decide evalhas pinned, and runs as shadow everywhere else.
New refusal codes
The hook gates and the gate SDKs refuse with three codes that 2.x gates never used. The hook gates that answer in JSON carry them as code beside ruleId, which is decide.<ruleRef>, for example decide.local:data.md#prod-data-export. The other hook gates print them on stderr with the rule id in brackets.
| Code | Meaning |
|---|---|
DECIDE_REFUSED | The rule asked its questions and the answers made its when true |
DECIDE_ESCALATED | The answers left when unknown and the harness cannot ask a person, or the rule's escalate is refuse |
GOVERNANCE_UNAVAILABLE | An enforcing rule could not be evaluated: the engine is not configured, its host is not self-hosted, it errored or was asked and did not answer in time, it returned no usable probabilities or failed its known-answer canary; or more than four enforcing rules name the tool. A rule the stored p95 says cannot fit the time left is not asked, and escalates (DECIDE_ESCALATED) instead |
The gate SDKs throw IntuticGateEscalation, a subclass of IntuticGateRefusal, for DECIDE_ESCALATED. Code that catches IntuticGateRefusal keeps refusing; code that knows the subclass can ask its user instead. GOVERNANCE_UNAVAILABLE was already a code of the proxy and the SDK clients; it is new on the gates.
Decide escalation by harness says where an escalation asks rather than refuses.
What 3.0 reads but does not enforce
on: responserules are enforced at the gateway, not by the gates, which see tool calls only. The gateway judges each complete response by them and writes aVIOLATIONorAMBIGUOUSnote into it, and askson: tool_callrules about the tool calls in a response too (Decide rules at the gateway). It reads the decide engine from~/.intutic/decide/engine.jsonand theINTUTIC_DECIDE_*variables when it starts. Adecide:line that does not read refuses its tools at every gate, and every response at the gateway.context: ["last_user_turn"]is reserved. A rule may declare it, and the rule's hash includes it, but no gate supplies the user's last turn, so the rule is asked without it.intutic decide testandintutic decide evalask without it too, even when the file has alastUserTurn, so an eval measures what a gate will ask.intutic decide lintandintutic gates installwarn about each rule that declares it.
The local judge's environment variables are deprecated
The shared gateway's local judge is configured by seven environment variables. They still work in 3.0: the gateway maps them to a decide rule, local-judge, and logs a deprecation warning once. 4.0 removes them.
| Variable | In 3.0 |
|---|---|
INTUTIC_GATEWAY_LOCAL_JUDGE | Still turns the local judge on |
LITELLM_LOCAL_URL | The model server the rule's questions are asked of (default http://litellm:4000). Its host is allowed as self-hosted because you named it; a hosted decision API is refused |
LITELLM_LOCAL_API_KEY | The bearer key sent to that server |
LITELLM_LOCAL_TYPED_JUDGE_MODEL | The model the rule's two questions are asked of |
LITELLM_LOCAL_JUDGE_MODEL | The free-text judge's model, and the questions' model when LITELLM_LOCAL_TYPED_JUDGE_MODEL is not set |
INTUTIC_GATEWAY_LOCAL_JUDGE_TYPED_LO, INTUTIC_GATEWAY_LOCAL_JUDGE_TYPED_HI | The rule's band: {no_below: σ(LO), yes_at: σ(HI)} |
With both band variables set, local-judge asks two yes/no questions about the response, a ("Does the ASSISTANT RESPONSE violate any of the ACTIVE SOP CONSTRAINTS?") and r1 (whether the agent actually took, instructed or produced something that breaks a constraint), and scores them with the combiner {bias: 0, weights: {a: 0.5, r1: 0.5}}: σ(½·logit(p_a) + ½·logit(p_r1)), the mean log-odds the typed judge scored with in 2.x. Its outcomes map back to the judge's verdicts:
| Rule outcome | Verdict |
|---|---|
refuse (score at or above HI) | VIOLATION; the free-text judge only writes the note |
allow (score below LO) | COMPLIANT |
| escalate (between them) | AMBIGUOUS |
Two things differ from 2.x. A score of exactly HI is now a violation, because a decide answer is yes at p ≥ yes_at. And inside the band the free-text verdict no longer decides: the response is AMBIGUOUS. HI must be above 0, since σ(0) is 0.5 and a decide band's yes_at must be above 0.5; a band that cannot be mapped logs one error, and the free-text judge decides as it does without a band.
Without the band variables there is no equivalent decide rule. The free-text verdict keeps deciding, with its own deprecation warning, until 4.0.
Decision calls are governed
The proxy now answers POST /v1/systemone and POST /v1/decisions itself, as decision traffic, instead of passing them through as unrecognised requests. Two things can refuse a call that 2.9 forwarded: a DLP block pattern in the state, and, in the default enforce mode, a model with no pinned version, such as jev-latest (403 policy_denied). Pin the version, or set decision_traffic.mode: shadow or decision_traffic.allow_unpinned: true while you move.