Skip to main content

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 key
  • project -- governs all API keys inside a project
  • org -- 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:

  1. api_key row for the requesting key
  2. project row for the requesting project
  3. org row for the organization
  4. 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:

SDKExceptionStable error code
PythonApprovalsDisabledE1500
NodeApprovalsDisabledE1500
Go*ApprovalsDisabledE1500

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:

CodeCauseOperator action
E1500Row exists at some scope with enabled = falseToggle the row on
E1704No row exists at any addressable scopeCreate 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.