Audit logging & the snapshot pattern

Two related ideas that govern how Midsummer mutates and records changes to historical data.

The snapshot pattern

At the moment a paid registration is created, certain derived data is snapshotted into Registration.data:

  • The tier’s included merchandise is copied into the submission JSON, with the attendee’s option selections (e.g. shirt size).

  • The registration level and its price are fixed.

This is intentional. A registration is a record of what someone bought at a point in time. If an organizer later edits a RegistrationLevel (changes its price, adds/removes an item), existing registrations are not retroactively changed — because that would silently rewrite history.

The consequences:

  • A forgotten tier item, or a later-removed item, has no automatic fix path.

  • The Merchandise Adjustment admin tool is the audited correction path: it mutates Registration.data under transaction.atomic() + select_for_update(), then writes a RegistrationMerchAdjustmentLog row.

  • The same “admin tools mutate the snapshot” principle applies to tier upgrades (RegistrationTierUpgradeAuditLog) and registrant edits/forwards (RegistrationEditLog).

Append-only audit logs

Every data-mutating admin surface has a dedicated append-only audit model:

Audit model

Records

BadgeNumberAuditLog

badge number assignments/changes

RegistrationMerchAdjustmentLog

merch add/remove with item snapshot

RegistrationTierUpgradeAuditLog

tier upgrades

RegistrationEditLog

edits to registrant data/form/account + forwards

staff.AuditLog

staff-module mutations

Each captures who (user), what (action + a small details JSONField), when, and a resilient reference to the target (e.g. a registration_uuid string snapshot that survives the registration itself being deleted).

The logging helper convention

Every audit surface wraps its write in a silent try/except helper:

def log_registration_edit(event, user, registration, action, details, notes=""):
    try:
        RegistrationEditLog.objects.create(...)
    except Exception:
        logger.exception("failed to write audit log")  # never raise

Important

A logging failure must never break a successful operation. When you add a new mutation + audit pair, follow this pattern: run the data mutation and the log write inside transaction.atomic() so they commit together, but if the log write itself is the thing that fails, swallow it rather than rolling back the user’s successful change.

Atomicity & scoped saves

Mutating views follow a consistent safety recipe:

  1. with transaction.atomic():

  2. re-fetch the row via .select_for_update() (prevents lost-update races on the JSON field),

  3. mutate data,

  4. save(update_fields=['data']) (scoped — don’t clobber unrelated columns),

  5. write the audit row.

This mirrors the established pattern in register/views.py (e.g. the tier- upgrade webhook path). Reuse it rather than inventing a new one.

Where to look

  • Audit models: register/models.py, staff/models.py

  • Mutation + audit recipe: register/views.py (merch_adjustment_add/remove, update_registrant, forward_registration, log_registration_edit)

  • Reference option rendering (canonical): register__merch_summary.py