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
settings.auto_approve_enabled(envMG_AUTO_APPROVE_ENABLED) →auto_approve_disabled_settings- Template lookup by
template_key→template_not_found - Template
auto_approve_enabled=True→template_auto_approve_disabled - Template
daily_cap_asset == intent.asset→asset_mismatch - Per-request:
intent.amount <= daily_cap_amount→amount_exceeded_per_request - 24h rolling SUM (other approved requests for same template, last 24h)
+ intent.amount <= daily_cap_amount→daily_cap_exceeded - Approved →
free_simple_cap_passed
Enabling a template for auto-approve
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 noSELECT 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.pyuses 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 viaBudgetLedgerEntry.
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 requiresstrategy_id and client_id on
the movement, because policy lookup keys on both.