Skip to content

AuditOperation is a string-constant set where the project's convention is an enum #351

Open

Description

@DutchJaFO

Background

AuditEntryEntity.Operation is a bare string, backed by twelve const string members on a static
AuditOperation class. The value set is closed and only grows when a new kind of auditable action is
designed — record-level operations written by the repository base class, plus admin actions written by
endpoint handlers. By this project's convention, a bounded set that does not grow during normal
operation is an enum, and a persisted enum-backed column carries a CHECK constraint per
ADR 008 — exactly as
ImportActionStatus, NotificationType, ImportBatchStatus and ChangeAction already do via
SafeValue<TEnum?>.

Audit_Entry.Operation is TEXT NOT NULL with no CHECK constraint, so today any string reaches the
column. Nothing validates that a written operation is one the application recognises, and nothing stops
a typo persisting silently into an append-only audit trail that exists to be read back long after the
fact.

This is a propagating deviation rather than a one-off. #348 added a twelfth member (BackupSkipped)
into the const-string shape, and #349 adds a thirteenth (BackupDeleted); each was added by matching
the shape already there. That is the same compounding pattern CLAUDE.md's Authoritative sources
section records for SystemAuditEntry and ADR 002 — an existing shape copied forward instead of
checked against the ADR that governs it.

Found while planning #349 (2026-08-29). Deliberately not absorbed into that issue: the conversion is a
schema change with a table rebuild, and #349 is an endpoint issue.

What needs to be done

  1. AuditOperation becomes an enum, with member names equal to the values currently persisted —
    Inserted, Updated, SoftDeleted, Restored, HardDeleted, Purged, Linked, Unlinked,
    BulkInserted, Reseeded, Reset, BackupSkipped, Imported, BackedUp, plus whatever members
    exist by the time this is implemented. Member name and stored text must round-trip through
    .ToString(), as every other SafeValue-backed enum in this codebase does; the current constant
    names differ from their values (Insert = "Inserted") and the values are what is on disk.

  2. The enum lives in its own Enums/ folder per CLAUDE.md's class-naming and enum-placement rule
    and ADR 016 — not
    nested beside the entity as the static class is today.

  3. AuditEntryEntity.Operation becomes SafeValue<AuditOperation?>, matching the established
    pattern for a persisted enum-backed column.

  4. A CHECK (Operation IN (...)) constraint is added, enumerating the enum's members. SQLite cannot
    add a CHECK to an existing column in place, so this is a table-rebuild migration —
    create-new-table, copy, drop, rename — appended to the migration list, never an edit of a released
    migration.

    This milestone's own migrations are consolidated before release, the same way Import-table naming standardization + general import-file content provenance (FileResource / FileResourceLine) #227 and Migration review: verify full incremental path from last-shipped v1.7.2 schema #155 squashed
    theirs, so this rebuild is expected to be folded into that assessment rather than shipping as its own
    numbered step. The frozen-forever boundary is the last released migration, not the last written one —
    which is what makes taking this on inside this milestone cheap rather than permanent.

  5. The baseline is updated to match the migration's final result, in the same commit, per
    CLAUDE.md's baseline rule.

  6. The schema-drift CHECK-constraint test is updated, so baseline and incremental replay are proven
    to accept the same values.

  7. Every existing on-disk value is in the CHECK list before the migration copies rows. A database
    written by any released build must migrate without the copy failing; a value the enum does not name
    must be identified before the rebuild, not discovered by a failing migration in a user's container.
    Released builds are the boundary that matters here for the same reason as requirement 4 — a value
    only ever written by an unreleased build on this branch is not in any user's database.

  8. A row carrying an unrecognised value still reads back, without throwing — the property
    SafeValue<T> exists for. An audit trail is append-only and read long after it was written.

  9. All call sites move to the enum — ~20 across src/ (SqliteRepository,
    SqliteRestorableRepository, SystemAuditWriter, QuotinatorDatabaseInitializer,
    SqliteImportActionService, AdminEndpoints) and ~30 across tests/.

  10. Check whether Operation needs an entry in SqlTextCaseGuard.AdditionalColumnNames. Reflection
    discovers it as a text column today because it is declared string; after the change its declared
    type is SafeValue<...> and it drops out of that discovery. It has no external-text filter
    parameter today (/admin/audit filters by table and recordId only), so the answer may be no —
    but it is the SystemImportActions.Status shape and must be decided rather than assumed.

Expected tests

Test class Test method Starts
AuditOperationSchemaTests Operation_HasACheckConstraintNamingEveryEnumMember ❌
AuditOperationSchemaTests Operation_RejectsAValueTheEnumDoesNotName ❌
AuditOperationSchemaTests Baseline_And_IncrementalReplay_AcceptSameOperationValues ❌
AuditEntryWriterTests EveryOperationMember_RoundTripsThroughTheColumn ❌
AuditEntryWriterTests UnrecognisedOperationOnDisk_ReadsBackWithoutThrowing ❌
AuditOperationMigrationTests RowsWrittenBeforeTheRebuild_SurviveIt_WithTheirOperationIntact ❌
AuditOperationMigrationTests EveryValueAnyShippedBuildCouldHaveWritten_PassesTheNewCheckConstraint ❌

Definition of done

  • All expected tests listed above start red before implementation
  • All requirements implemented
  • All expected tests pass (green)
  • No regression in related tests
  • Findings summarised in a closing comment

Activity

  1. added this to the Notification system milestone on Aug 29, 2026
  2. added 2 commits that reference this issue on Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions