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:
co-authored by
Claude Opus 5
parent
2c46d69d21
commit
77becf3d65
@@ -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')
|
||||
|
||||
@@ -1179,6 +1179,22 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
company_subscriptions: 'billing state',
|
||||
deadlines: 'regenerable operational calendar state',
|
||||
dimension_retag_log: 'operation log',
|
||||
// Verification metadata ABOUT räkenskapsinformation, not räkenskapsinformation
|
||||
// itself: one row per nightly SHA-256 recompute of an archived document
|
||||
// (migration 20260901130000). The documents ship under dokument/ with their
|
||||
// upload-time hash in dokument/manifest.json, so a recipient can re-verify
|
||||
// every file from the archive alone, without our check log. The checks that
|
||||
// do carry legal weight are the failures, and those are already written to
|
||||
// audit_log as INTEGRITY_FAILURE and exported in
|
||||
// revision/behandlingshistorik.json; a passing check is evidence that our
|
||||
// cron ran, which belongs to the platform and not to the company's books.
|
||||
// Erasure needs nothing either: the ledger holds no personal data of its own
|
||||
// (company_id, document_id, hashes, storage key), it inherits
|
||||
// document_attachments' posture on the user id embedded in a legacy storage
|
||||
// key, and its rows go with the ON DELETE CASCADE from companies and
|
||||
// document_attachments when the underlying data is legally removed.
|
||||
document_integrity_checks:
|
||||
'WORM verification log (SHA-256 recompute outcomes); failures reach the archive via audit_log in revision/behandlingshistorik.json',
|
||||
event_log: '30-day TTL event bus log',
|
||||
extension_data: 'extension runtime state (includes this backup\'s own state)',
|
||||
graph_counterparties: 'derived AI context graph, regenerable',
|
||||
|
||||
Reference in New Issue
Block a user