Repository navigation
AuditOperation is a string-constant set where the project's convention is an enum #351
Description
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
-
AuditOperationbecomes 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 otherSafeValue-backed enum in this codebase does; the current constant
names differ from their values (Insert="Inserted") and the values are what is on disk. -
The enum lives in its own
Enums/folder perCLAUDE.md's class-naming and enum-placement rule
and ADR 016 — not
nested beside the entity as the static class is today. -
AuditEntryEntity.OperationbecomesSafeValue<AuditOperation?>, matching the established
pattern for a persisted enum-backed column. -
A
CHECK (Operation IN (...))constraint is added, enumerating the enum's members. SQLite cannot
add aCHECKto 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. -
The baseline is updated to match the migration's final result, in the same commit, per
CLAUDE.md's baseline rule. -
The schema-drift CHECK-constraint test is updated, so baseline and incremental replay are proven
to accept the same values. -
Every existing on-disk value is in the
CHECKlist 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. -
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. -
All call sites move to the enum — ~20 across
src/(SqliteRepository,
SqliteRestorableRepository,SystemAuditWriter,QuotinatorDatabaseInitializer,
SqliteImportActionService,AdminEndpoints) and ~30 acrosstests/. -
Check whether
Operationneeds an entry inSqlTextCaseGuard.AdditionalColumnNames. Reflection
discovers it as a text column today because it is declaredstring; after the change its declared
type isSafeValue<...>and it drops out of that discovery. It has no external-text filter
parameter today (/admin/auditfilters bytableandrecordIdonly), so the answer may be no —
but it is theSystemImportActions.Statusshape 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
docs [#351]: file the audit-operation enum deviation #349 surfaced
docs: bring the notification-system overview into compliance with pro…