fix(documents): record archive integrity checks in their own ledger so the nightly control advances again (#2108)

The 03:00 WORM verification cron stamped last_integrity_check_at on
document_attachments. enforce_period_lock_documents() fires on any UPDATE of a
row whose journal entry sits in a closed or locked period, without checking
whether the entry link actually changed, so a read-only integrity stamp was
rejected. The queue orders last_integrity_check_at ASC NULLS FIRST, so the
rejected rows re-sorted to the head every night and the batch became
permanently 200/200 blocked. Both call sites discarded the update error, so
nothing logged and nothing alerted.

Prod state: 34 557 current-version documents, 24 083 never checked, last
successful stamp 2026-08-31 03:00, nightly successes already decayed to
single digits.

Migration 017's enforcement triggers are legally required and never-touch, so
this does not narrow the trigger. The verification outcome moves to its own
document_integrity_checks table and the cron stops writing document_attachments
altogether, which takes the trigger off the write path. The legacy column stays
in place. Failures are now counted, logged and reported in the route's summary:
the silence is why this went unnoticed for weeks.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 14:23:05 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 2c46d69d21
commit 77becf3d65
7 changed files with 934 additions and 74 deletions
+11
View File
@@ -834,6 +834,17 @@ export async function uploadDocument(
if (error) {
if (reservedDocumentId && error.code === '23505') {
// The column list mirrors the DocumentAttachment interface one for one,
// so the row this returns is the stored row and nothing else.
// last_integrity_check_at stays in it even though it is now legacy
// (migration 20260901130000 moved the verification stamp to
// document_integrity_checks, and nothing writes this column any more):
// this branch re-reads a row a concurrent request inserted seconds ago,
// where the column is NULL by construction, and no caller interprets the
// value. Dropping it would leave the returned object short of a key the
// type declares; joining the new ledger for it would fetch a check that
// cannot exist yet. Whoever wants "when was this document last verified"
// reads document_integrity_checks, never this field.
const { data: concurrent, error: concurrentError } = await supabase
.from('document_attachments')
.select('id, user_id, company_id, storage_path, file_name, file_size_bytes, mime_type, sha256_hash, version, original_id, superseded_by_id, is_current_version, uploaded_by, upload_source, digitization_date, journal_entry_id, journal_entry_line_id, prev_version_hash, last_integrity_check_at, created_at, updated_at')