Approval settings
The Approvals feature is governed by a per-scope toggle stored in the
hitl_settings table. A row exists at one of three scopes:
api_key-- governs requests carrying a specific API keyproject-- governs all API keys inside a projectorg-- governs every project in the organization
Cascade order
When the SDK calls request_approval(), the backend resolves the
effective toggle by walking the scope chain from most specific to
least specific. The first row that exists wins:
api_keyrow for the requesting keyprojectrow for the requesting projectorgrow for the organization- default:
enabled = false(fail-closed)
The resolver does not merge rows. Whichever level supplies a row also
supplies the final enabled value. If a project row has
enabled = false, no org-scope row can override it.
Default behavior is off
If no hitl_settings row exists at any scope, the cascade falls
through to the default and treats approvals as disabled. This is
intentional: an unconfigured organization cannot silently accept
approval traffic that no human will ever review. An administrator
must opt in by creating at least one row.
This is consistent with the platform-wide fail-closed posture for unknown configuration.
When approvals are disabled
A request_approval() call against a scope that resolved to
enabled = false returns:
- HTTP 412 Precondition Failed
- Body:
{"error": "approvals_disabled_at_scope", "resolved_scope": "<scope>"}
The SDK translates this into a typed exception you can catch:
| SDK | Exception | Stable error code |
|---|---|---|
| Python | ApprovalsDisabled | E1500 |
| Node | ApprovalsDisabled | E1500 |
| Go | *ApprovalsDisabled | E1500 |
The exception carries the resolved_scope so the caller can tell the
operator which row to edit.
Python
from controlzero import Client
from controlzero.errors import ApprovalsDisabled
client = Client()
try:
pending = client.request_approval(decision)
except ApprovalsDisabled as exc:
print(f"Enable approvals at scope: {exc.resolved_scope}")
Node
import { Client, ApprovalsDisabled } from '@controlzero/sdk';
const client = new Client();
try {
const pending = await client.requestApproval(decision);
} catch (err) {
if (err instanceof ApprovalsDisabled) {
console.log(`Enable approvals at scope: ${err.resolvedScope}`);
}
}
Go
import (
"errors"
"github.com/control-zero/controlzero-go"
)
_, err := client.RequestApproval(ctx, decision, controlzero.RequestApprovalOpts{})
var disabled *controlzero.ApprovalsDisabled
if errors.As(err, &disabled) {
fmt.Printf("Enable approvals at scope: %s\n", disabled.ResolvedScope)
}
// Or, scope-agnostic:
if errors.Is(err, controlzero.ErrApprovalsDisabled) {
// Generic handling
}
Distinguishing from a 404
A 404 from POST /api/approval-requests becomes
HITLNotConfiguredError (E1704) instead. The semantic difference:
| Code | Cause | Operator action |
|---|---|---|
| E1500 | Row exists at some scope with enabled = false | Toggle the row on |
| E1704 | No row exists at any addressable scope | Create the row at the desired scope |
In practice, modern backends always resolve to default rather than
404, so most callers only need to handle E1500.
Auditing which scope governed a request
When a request_approval() POST succeeds and the backend queues the
row, the approval request's context map carries
resolved_scope: "<scope>". The dashboard's approval detail page
surfaces this so reviewers can see which row was in effect when the
request was created.
The metric controlzero_hitl_approval_cascade_total{resolved_scope,outcome}
reports the same value on every POST, broken out by enabled and
disabled outcomes.