● Compliance & Security
service-invariant-guard
Scans for direct mutations of aggregate or computed fields that should only be modified through a centralized service. Catches bypasses of HeadCountService, BalanceService, InventoryService, or any domain service that owns a critical invariant. Configurable for any domain.
Service Invariant Guard
Detect code that directly mutates fields which should only be modified through a designated service. Prevents invariant violations where aggregate counts, computed balances, or state machines drift out of sync.
When to Use
- After adding code that could modify a guarded field
- Before PRs that touch models with service-managed fields
- During code review of new contributors
- Periodically as a codebase hygiene check
- After refactoring service boundaries
The Problem
Many systems have fields that LOOK like simple model attributes but are actually service-managed invariants:
# DANGEROUS — bypasses the service that keeps counts in sync
lot.current_head_count -= 1
lot.save()
# SAFE — uses the service that updates counts, assignments, and audit trail
HeadCountService.record_mortality(lot, pen, head_count=1, user=request.user)
When code bypasses the service:
- Related records don't update (e.g., LotPenAssignment, HospitalPenOccupant)
- Audit trail is incomplete
- Computed properties diverge from stored values
- Business rules are skipped (e.g., lot closure check, notification dispatch)
Workflow
Step 1: Define Invariants
Read project documentation or configure the invariant registry:
INVARIANT REGISTRY
==================
┌─────────────────────────────────┬─────────────────────────┬──────────────────────────────┐
│ Guarded Field │ Owning Service │ Allowed Mutation Points │
├─────────────────────────────────┼─────────────────────────┼──────────────────────────────┤
│ Lot.current_head_count │ HeadCountService │ process_cattle_inbound, │
│ │ │ process_cattle_outbound, │
│ │ │ record_mortality, │
│ │ │ transfer_between_lots │
│ │ │ │
│ Pen.current_head_count │ (computed property) │ NEVER — it's a @property │
│ │ │ │
│ FeedLoad.remaining_tons │ FeedInventoryDeduction │ deduct_for_event, │
│ │ Service │ reverse_deduction │
│ │ │ │
│ PharmaBatch.remaining_quantity │ PharmaceuticalInventory │ deduct_for_treatment, │
│ │ DeductionService │ reverse_deduction │
│ │ │ │
│ Lot.status │ HeadCountService │ _try_close_lot (via signal) │
│ │ │ │
│ BillingUsage.peak_head_count │ BillingUsage signal │ post_save on Lot │
└─────────────────────────────────┴─────────────────────────┴──────────────────────────────┘
Step 2: Build Search Patterns
For each guarded field, construct grep patterns that detect direct mutation:
# Pattern: Direct attribute assignment
f"{model_name_lower}.{field_name}\s*[+\-\*]?="
# Matches: lot.current_head_count = 50
# lot.current_head_count -= 1
# lot.current_head_count += head_count
# Pattern: update() calls
f"\.update\(.*{field_name}\s*="
# Matches: Lot.objects.filter(...).update(current_head_count=F('current_head_count') - 1)
# Pattern: save() with update_fields containing the field
f"save\(.*update_fields.*{field_name}"
# Matches: lot.save(update_fields=['current_head_count'])
# Pattern: bulk_update with the field
f"bulk_update\(.*\[.*'{field_name}'"
# Matches: Lot.objects.bulk_update(lots, ['current_head_count'])
# Pattern: F() expressions on the field
f"F\('{field_name}'\)"
# Matches: Lot.objects.update(current_head_count=F('current_head_count') - 1)
Step 3: Scan and Classify
For each match found:
CLASSIFICATION
==============
✅ AUTHORIZED — Match is inside the owning service:
lots/services.py:HeadCountService.process_cattle_inbound()
lot.current_head_count += head_count ← This IS the service
✅ MIGRATION — Match is in a data migration:
lots/migrations/0058_restore_hospital_head_counts.py
lot.current_head_count += occupant_count ← One-time data fix, acceptable
⚠️ LEGACY — Match predates the service (may need refactoring):
health/models.py:MortalityEvent.save()
lot.current_head_count -= 1 ← Direct update, legacy pattern
DOCUMENTED: "legacy pattern" in lots/CLAUDE.md
❌ VIOLATION — Match is outside the service with no justification:
sorts/views.py:145
source_lot.current_head_count -= transfer.head_count
SHOULD BE: HeadCountService.transfer_between_lots(...)
❌ COMPUTED PROPERTY WRITE — Attempting to write to a computed field:
health/views.py:89
pen.current_head_count = new_count
INVALID: Pen.current_head_count is a @property (read-only)
Step 4: Check for Missing Service Calls
Beyond direct mutations, check that the service is actually being called where it should be:
MISSING SERVICE CALLS
=====================
Scan for operations that SHOULD use the service but don't call it:
1. Cattle arrival without HeadCountService:
grep -rn "CattleInbound.*create\|CattleInbound.*save" --include="*.py"
→ Verify each is followed by HeadCountService.process_cattle_inbound()
2. Mortality without HeadCountService:
grep -rn "MortalityEvent.*create\|MortalityEvent.*save" --include="*.py"
→ Verify each calls HeadCountService.record_mortality()
3. Inventory deduction without service:
grep -rn "remaining_tons.*-=\|remaining_quantity.*-=" --include="*.py"
→ Should use FeedInventoryDeductionService or PharmaceuticalInventoryDeductionService
Step 5: Generate Guard Report
SERVICE INVARIANT GUARD REPORT
===============================
Invariants checked: 6
Files scanned: 85
Mutations found: 34
Guarded Field Authorized Legacy Violation Computed Write
──────────────────────────────────────────────────────────────────────────────
Lot.current_head_count 12 1 2 0
FeedLoad.remaining_tons 8 0 0 0
PharmaBatch.remaining_quantity 6 0 1 0
Lot.status 3 0 0 0
Pen.current_head_count 0 0 0 1
BillingUsage.peak_head_count 1 0 0 0
VIOLATIONS (ACTION REQUIRED):
#1 sorts/views.py:145
source_lot.current_head_count -= transfer.head_count
FIX: Replace with HeadCountService.transfer_between_lots()
#2 sorts/views.py:148
dest_lot.current_head_count += transfer.head_count
FIX: Same — HeadCountService handles both sides
#3 processing/services.py:89
batch.remaining_quantity -= quantity
FIX: Use PharmaceuticalInventoryDeductionService.deduct()
COMPUTED PROPERTY WRITES:
#1 health/views.py:89
pen.current_head_count = new_count
FIX: This is a @property — mutation is silently ignored. Remove the line.
LEGACY (TRACK FOR FUTURE REFACTOR):
#1 health/models.py:MortalityEvent.save()
lot.current_head_count -= 1
NOTE: Documented as legacy in lots/CLAUDE.md. Refactor to HeadCountService.record_mortality()
Configuration
# .service-invariant-guard.yml (or configure inline)
invariants:
- model: Lot
field: current_head_count
service: HeadCountService
service_file: lots/services.py
allowed_files:
- lots/services.py
- lots/migrations/*
- model: FeedLoad
field: remaining_tons
service: FeedInventoryDeductionService
service_file: inventory/services.py
allowed_files:
- inventory/services.py
- inventory/migrations/*
- model: PharmaBatch
field: remaining_quantity
service: PharmaceuticalInventoryDeductionService
service_file: inventory/services.py
allowed_files:
- inventory/services.py
- inventory/migrations/*
- model: Pen
field: current_head_count
type: computed_property # NEVER writable
exclude_dirs:
- tests/
- migrations/ # Optional: include to catch migration issues
Framework Adaptations
Django
# Guarded fields are often on Model classes
# Services are typically in <app>/services.py
# Computed properties use @property decorator
# Signals can bypass services — check signals.py files
FastAPI + SQLAlchemy
# Guarded fields are on SQLAlchemy models
# Services are in <module>/service.py
# Computed properties use @hybrid_property
# Event listeners can bypass services
Rails
# Guarded fields are ActiveRecord attributes
# Services are in app/services/
# Computed properties use def field_name; end
# Callbacks (before_save, after_create) can bypass services
Key Principles
- The service IS the invariant — if code bypasses the service, the invariant is broken
- Migrations are special — data migrations may need direct access for one-time fixes
- Tests are exempt — test setup code can set fields directly for test isolation
- Legacy code is tracked — don't ignore it, document it for future refactoring
- Computed properties are NEVER writable — writing to a @property is a silent no-op bug
- Signals are sneaky — a post_save signal might directly mutate a guarded field in another model