Notes are overwritten with no history and an empty audit record #66
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM
The bug
backend/app/services/projections.py:79-95overwrites notes destructively and emits an auditevent carrying no values:
Compare
apply_decision, which correctly records full before/after state incontext.Why it matters
Notes are where a reviewer records what a relative told them — often the only surviving record
of who is in a photograph. A reviewer who clears the field, or a script that PATCHes notes to an
empty string across hundreds of photos, destroys that permanently. The audit log proves someone
changed notes but cannot say what was lost or restore it.
Contradicts the spec
docs/circa-spec.md§6.5: "All user-visible edits to dates, notes, duplicate resolutions,group assignments, and metadata corrections must preserve previous values." Notes are the one
field with no history at all.
Fix
Minimum: include
{"old": previous, "new": new_notes}in the auditcontext.Better: add an append-only
PhotoNoteRevisiontable mirroring how decisions are handled, andsurface note history in the review workspace alongside the decision history tab.
Done when
References
backend/app/services/projections.py:79-95backend/app/api/routes/photos.py:203-222docs/circa-spec.md§6.5Done in
c95fe79. Took the "better" option in the issue rather than the minimum — the append-only table and before/after in the audit context.Revisions store the value the notes were set to, not the value replaced. That direction is what keeps
notes_currentderivable from history the way §6.5 requires of every projection; storing the outgoing value would leave the newest text with no row of its own and make the projection unrebuildable.The old/new duplication into the audit event is deliberate, not redundancy I missed.
audit_eventis the table protected by #67's append-only triggers, so it is the copy that survives someone who can reach the database.photo_note_revisionhas no such protection — it is what the UI reads. There's a test that deletes every revision row and reconstructs the lost note from the ledger alone. The context also carries aclearedflag, because emptying the field is the destructive case and the one worth grepping the ledger for.Migration
005backfills a genesis revision for notes that predate the table. Without it the loss isn't prevented, only deferred by one edit — the first change after deployment would append the new text and leave what it replaced unrecorded, which is exactly this bug. Those rows are honest about being reconstructed:created_byis NULL (renders as "System" rather than falsely attributing to someone) andcreated_atusesphoto.updated_at, an upper bound rather than the moment the notes were written — the only signal the old schema preserved.apply_notes_updatedis the sole writer ofnotes_current(ingest doesn't touch it), so after the backfill the column is fully accounted for. Tested across the 004→005 boundary, since the normal harness builds at head and can't reach it.A save that changes nothing now writes nothing and no longer bumps
review_version, followingapply_flags_updated. A spurious bump invalidates other reviewers' in-flight edits for no reason.UI:
GET /api/photos/{id}/notes/revisions(readable by anyone who may read the photo — viewers still get 404 on unapproved ones), surfaced behind a History toggle in the Notes panel. Cleared revisions render as "Notes cleared" rather than blank.Unrelated live bug found while wiring the UI, fixed here: #57/#64 removed raw
created_byids from API responses, butReviewWorkspacePagestill calledc.created_by.slice(0, 8)— aTypeErroronundefinedas soon as a photo had a comment, i.e. the Comments tab was crashing onmain.tsccouldn't catch it because the TS interfaces still declared the removed field. Interfaces now extend a sharedAttributiontype. This is a concrete argument for #76 (generate TS types from OpenAPI); flagging it there.All four "done when" items covered, 30 tests in
backend/tests/test_note_history.py.Not done:
photo_note_revisionhas no append-only triggers of its own.date_decisionanddate_evidencedon't either — the ledger is deliberately the one special-cased table, and widening that is a scope call rather than something to slip in here. Say if you'd like it and I'll open an issue.