Immutability & Audit Trail Architecture

How CommunityPay enforces record immutability at the application layer. Which models are immutable, which fields are locked, and how tamper detection works across the platform.

7 min read Security & Infrastructure As of Feb 9, 2026

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:

  1. 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.

  2. 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:

  1. Collect values from _hashable_fields (defined by the subclass)
  2. Serialize each value to a deterministic string representation
  3. Produce canonical JSON (sorted keys)
  4. Compute SHA-256 of the resulting bytes
  5. 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:

  1. Load the record
  2. Extract the hashable content
  3. Serialize to canonical JSON (sorted keys, minimal separators, UTF-8)
  4. Compute SHA-256
  5. 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 UPDATE or DELETE statements 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 EnforcementDecision records cannot be updated via save()
  • Existing EligibilityEvaluation records raise PermissionDenied on save
  • Existing ExclusionTriggerHit records raise PermissionDenied on save
  • Existing PaymentStateHistory records raise ValueError on save
  • AuditLog records cannot be deleted
  • Active EligibilityRule criteria cannot be modified
  • InstitutionalPacket immutable 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
Login