CommunityPay's audit trail depends on one property: records that should not change cannot change. This page describes how that property is enforced, which records it applies to, and what "immutable" means in practice at the application layer.
Why Application-Layer Immutability
Database-level constraints (row-level security, triggers, permissions) can enforce immutability, but they can also be bypassed by database administrators, migration scripts, or direct SQL access. Application-layer immutability adds a second enforcement boundary.
In CommunityPay, immutability is enforced by overriding Django's save() and delete() methods on the model itself. Any code path that attempts to modify an immutable record — whether through the ORM, the admin interface, management commands, or bulk operations — triggers the same rejection.
This is defense in depth. Database constraints are the foundation. Application-layer enforcement is the guarantee that the codebase itself cannot accidentally or intentionally circumvent the constraint through normal Django operations.
ImmutableModelMixin
The primary enforcement mechanism is ImmutableModelMixin, a mixin class that provides uniform immutability behavior.
How It Works
When a model inherits from ImmutableModelMixin:
On save:
- If the record already exists (update, not insert), the mixin checks which fields are being updated
- If update_fields is specified, it computes the set difference against _mutable_fields
- If any immutable field is in the update set, a PermissionDenied exception is raised
- If no update_fields is specified and the model has no mutable fields, the entire save is rejected
On delete:
- All deletes are rejected unconditionally with a PermissionDenied exception
- The error message directs callers to use status fields for logical deletion
Mutable Field Override
Models that need partial immutability override the _mutable_fields property to return the set of fields that can be updated after creation. All other fields are locked.
immutable_updates = set(update_fields) - mutable - {'modified_at', 'updated_at'}
if immutable_updates:
raise PermissionDenied(...)
Timestamp fields (modified_at, updated_at) are always permitted to update. They track when the record was last touched, not what the record contains.
Integrity Verification
The mixin provides a _check_immutable_integrity() class method that returns metadata about the model's immutability configuration — whether it is fully immutable, and which fields (if any) are mutable. This supports automated verification that immutability contracts have not been accidentally weakened.
Fully Immutable Models
These models reject all updates after creation. Once written, the record cannot be modified or deleted through the application layer.
| Model | Location | Enforcement | Purpose |
|---|---|---|---|
| EnforcementDecision | accounting/models | ImmutableModelMixin | Every financial authorization decision |
| IntegritySnapshot | accounting/models | ImmutableModelMixin + content hash | Ledger integrity scan results |
| EligibilityEvaluation | accounting/models | Custom save/delete override | Eligibility rule evaluation records |
| ExclusionTriggerHit | accounting/models | Custom save/delete override | Trigger evaluation records |
| ExclusionStatusHistory | accounting/models | Custom save override | Exclusion status change log |
| ExclusionNotificationEvent | accounting/models | Custom save override + content hash | Notification delivery proof |
| GovernancePolicySnapshot | accounting/models | Custom save override + config hash | Policy state at decision time |
| PaymentStateHistory | payments/models | Custom save override | Payment lifecycle transitions |
| DisputeStatusHistory | payments/models | Custom save override | Dispute status change log |
| AuditLog | audit/models | Custom save/delete override + checksum | Platform-wide audit log |
Common Pattern
Models that do not use ImmutableModelMixin enforce immutability with the same logic, implemented directly:
def save(self, *args, **kwargs):
if self.pk:
raise PermissionDenied("... is immutable — cannot modify after creation.")
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
raise PermissionDenied("... is immutable — cannot delete audit records.")
The effect is identical. Both patterns prevent modification of existing records through any Django ORM code path.
Partially Immutable Models
These models allow updates to specific fields while locking others.
EligibilityRule and ExclusionTrigger
Rules and triggers follow a version lifecycle: DRAFT, ACTIVE, DEPRECATED, ARCHIVED. While in DRAFT, the rule's criteria (or trigger's conditions) can be modified. Once the status is set to ACTIVE, the criteria are locked.
How locking works:
On every save, the model computes a SHA-256 hash of the criteria JSON (sorted keys, deterministic serialization). If the record already exists and its current status is ACTIVE, the model compares the stored criteria hash against the newly computed hash. If they differ, a ValueError is raised:
"Cannot modify criteria of ACTIVE rule. Create new version instead."
This means the criteria that were evaluated in production can never be retroactively changed. If the rule needs to evolve, a new version is created. The old version's evaluations remain tied to the old criteria, preserving audit reproducibility.
InstitutionalPacket
Institutional packets (HDEP, GCA, FADR, VECR, RC, RSR) use a whitelist model. The model defines an explicit MUTABLE_FIELDS set:
- status, delivered_at, voided_at, void_reason
- pdf_file (PDF can be re-rendered from immutable snapshot)
- is_shareable, share_token, share_expires_at
- shared_with_email, notes
- is_current, superseded_at, generated_at, generation_time_ms
Every other field — including evidence_snapshot, content_hash, public_id, reference_number, scope, as_of_at, and created_by — is locked after creation.
On save, if the update targets any field not in MUTABLE_FIELDS, a ValueError is raised. If no update_fields is specified, the model loads the original record from the database and compares every non-mutable field. If any has changed, the save is rejected.
An additional guardrail protects evidence_snapshot and as_of_at specifically: even though they are not in MUTABLE_FIELDS, the model verifies that once generated_at is set (indicating the packet has been finalized), these fields cannot be touched even through a full save.
Content Hashing
Several immutable models include SHA-256 content hashes computed at creation time. These hashes serve two purposes:
-
Tamper detection: Any modification to the record's content (through database-level access that bypasses the application layer) can be detected by recomputing the hash and comparing it to the stored value.
-
Chain integrity: Some models (InstitutionalPacket) link records via
previous_packet_hash, creating a hash chain. If any record in the chain is modified, the chain breaks at that point.
Models with Content Hashes
| Model | Hash Field | What Is Hashed |
|---|---|---|
| AuditLog | checksum | category, event_type, user_id, ip_address, message, metadata, amount, created_at |
| IntegritySnapshot | content_hash | Full scan results via ContentHashMixin |
| ExclusionNotificationEvent | content_hash | content_snapshot JSON (sorted keys) |
| PolicySnapshot | rules_hash + snapshot_hash | Canonicalized rules JSON; complete snapshot |
| InstitutionalPacket | content_hash | Canonical JSON of evidence_snapshot |
ContentHashMixin
The ContentHashMixin provides a standard implementation for content hash computation:
- Collect values from
_hashable_fields(defined by the subclass) - Serialize each value to a deterministic string representation
- Produce canonical JSON (sorted keys)
- Compute SHA-256 of the resulting bytes
- Store the hex digest in
content_hash
The mixin also provides verify_content_hash(), which recomputes the hash from current field values and compares it to the stored hash. This enables periodic integrity verification without requiring the original source data.
Verification
For models with content hashes, verification follows a standard pattern:
- Load the record
- Extract the hashable content
- Serialize to canonical JSON (sorted keys, minimal separators, UTF-8)
- Compute SHA-256
- Compare with stored hash
If the hashes match, the content has not been modified since creation. If they do not match, the record has been tampered with at a layer below the application (direct database access, migration script, etc.).
What Immutability Does Not Prevent
Application-layer immutability prevents modification through Django's ORM. It does not prevent:
- Direct SQL
UPDATEorDELETEstatements executed against the database - Database administrator actions
- Backup restoration that overwrites records
- Migration scripts that modify data
Content hashing addresses the first three scenarios by making tampering detectable. If a record is modified through direct SQL, the content hash will no longer match, and the next verification check will flag it.
The combination of application-layer enforcement (prevention) and content hashing (detection) provides defense in depth. Prevention stops accidental or programmatic modification. Detection catches everything else.
Test Coverage
Immutability enforcement is validated by automated tests that verify:
- Existing
EnforcementDecisionrecords cannot be updated viasave() - Existing
EligibilityEvaluationrecords raisePermissionDeniedon save - Existing
ExclusionTriggerHitrecords raisePermissionDeniedon save - Existing
PaymentStateHistoryrecords raiseValueErroron save AuditLogrecords cannot be deleted- Active
EligibilityRulecriteria cannot be modified InstitutionalPacketimmutable fields cannot be updated
These tests run in CI. If a future code change weakens an immutability contract, the test suite will catch it before deployment.
How CommunityPay Enforces This
- ImmutableModelMixin enforces save() and delete() rejection at the application layer — not just database constraints
- 13 models enforce full or partial immutability across accounting, payments, and audit systems
- SHA-256 content hashes on AuditLog, IntegritySnapshot, PolicySnapshot, ExclusionNotificationEvent, and InstitutionalPacket
- Versioned rules (EligibilityRule, ExclusionTrigger) lock criteria hash once ACTIVE — changes require new versions
- InstitutionalPacket uses explicit MUTABLE_FIELDS whitelist — unlisted fields raise ValueError on update