Skip to main content

Auto-Approve — Free vs Pro

QTG’s auto-approve subsystem lets pre-defined movement templates auto-approve small, well-bounded transfers without operator intervention. There are two implementations: Both implement the same AutoApproveHook protocol (src/qtg/domain/protocols.py); the registry (pro_registry.py) selects which one is live.

Free hook: _SimpleCapAutoApproveHook

Source: src/qtg/application/services/free_auto_approve.py

Evaluation order

  1. settings.auto_approve_enabled (env MG_AUTO_APPROVE_ENABLED) → auto_approve_disabled_settings
  2. Template lookup by template_keytemplate_not_found
  3. Template auto_approve_enabled=Truetemplate_auto_approve_disabled
  4. Template daily_cap_asset == intent.assetasset_mismatch
  5. Per-request: intent.amount <= daily_cap_amountamount_exceeded_per_request
  6. 24h rolling SUM (other approved requests for same template, last 24h) + intent.amount <= daily_cap_amountdaily_cap_exceeded
  7. Approved → free_simple_cap_passed

Enabling a template for auto-approve

The PATCH route returns the updated row and writes a plan_template.update_admin_fields audit row (Plan 1 boot_check enforces descriptor presence). DB-level CHECK constraint blocks auto_approve_enabled=true with NULL caps (cap_consistency_violation → 422).

TOCTOU caveat

Free hook has no SELECT FOR UPDATE. Two concurrent submits both pass the 24h SUM check before either commits, so cumulative cap can overshoot.
  • Operating assumption: single QTG operator, low-concurrency desktop use. Empirically the race never fires; QTG is the only writer.
  • Test invariant: tests/qtg/test_free_simple_cap_toctou_documented.py uses a monkeypatched barrier to reproduce the race deterministically and asserts both submits pass when cumulative would exceed cap. The test documents the failure mode — it does not “fix” it.
  • Escape hatch: enable the Pro hook (register_auto_approve_hook(AutoApproveHookImpl())) for exact serializable cap enforcement via BudgetLedgerEntry.

Pro overrides Free

When Pro is installed its hook is registered at bootstrap and takes precedence; the Free hook is never invoked. The Pro path additionally requires strategy_id and client_id on the movement, because policy lookup keys on both.

Settings reference