Skip to content

M3-08 Cleanup and deletion of files

No Date Ruling Reason
D-M3-08-1 2026-10-23 files declares the source port DraftFileSource (HeldByUploader, UploaderFile) in its root package and sync implements it in adapters/postgres.DraftFiles with the query HeldByUploaderDrafts; sync may use files (02 4.3 and 4.4, architecture/modules.yaml, the edge sync --> files of the component diagram, the sync README). drafts belongs to sync, which files may neither read nor import (03 principle 12, 02 4.4 "Tiers"); the edge points down, so the graph stays acyclic.
D-M3-08-2 2026-10-23 The one function that writes files.deleted_at (markDeleted in files/app, now deletion.mark) takes its further steps only for the files its UPDATE ... RETURNING id marked: an open upload session becomes aborted without file.updated, the stored file.updated events of the file are redacted through eventlog.Redactor, and files.purge_objects is inserted with InsertTx to run at once. It writes no event. Callers: DeleteForMessage, ReleaseImage (M3-10), Delete (deleteFile) and the cleanup steps of never attached files and failed rows. Mark, redaction and job commit together and a rollback leaves none of them (02 8.5); a second call has no effect; a deleted file answers 404 to every reader, so no event is needed, and 05 6 has none.
D-M3-08-3 2026-10-23 The redacted file.updated (schema version 1) keeps id, uploaded_by, purpose, upload_state, scan_status, transcript_status and created_at, sets original_name to "", size_bytes to 0, mime_type to application/octet-stream and thumbnails to [], and drops every other member (domain.RedactFileEvent); any other type or version on resource kind file fails the deleting unit of work. Every file.updated carries the name and digest of the file, which 20 4.2 and DP-T01 require to be gone (R-DM-45); the result stays valid against FileUpdatedEvent, holds no JSON null and is a fixed point.
D-M3-08-4 2026-10-23 files.purge_objects reads its facts without a transaction and plans with domain.PlanPurge: no row does nothing, a deleted row deletes objects and then the row, a final not deliverable file (failed, infected, error) deletes only its objects, every other file nothing. The keys are orig-1 to orig-(r+1) (r the revision of the stored key) and the four variant keys (domain.ObjectKeysOf); the parts go through the part strategy (Discard). The row is deleted in one unit of work after a lock and a check that it is still deleted and not attached; dependent rows follow by ON DELETE CASCADE. Keys come only from the key schema and the row, never from a listing (D-FI-11); r + 1 covers an original a processing run wrote but did not reference yet; the row holds the name, digest and text, which must go (R-DM-46); a stray job never touches a live file.
D-M3-08-5 2026-10-23 recordFileChange inserts files.purge_objects in the same unit of work whenever a change makes a file final without making it deliverable: the abort, the failures of the verify pass, infected and error, the expiry of the cleanup and the failure transaction of the image job. The deletes after the commit of M3-03 and M3-04 stay. The job commits with the transition and so removes what a crash or a failed delete after the commit left (the residual of M3-04 design note 6 f).
D-M3-08-6 2026-10-23 files.cleanup_pending reads the clock once and runs in this order: expired sessions, never attached files, failed and blocked rows, the catch-up of purges, the temporary files, the backlog. Each step reads candidates in batches of file.gc_batch_size with a key of (time, id) and handles each candidate in its own unit of work; a database error ends only its step with one Warn line, and the run returns the first error after the last step. The catch-up runs Purge.Run inline instead of inserting jobs and stops at the first storage error until the next run. One failing candidate never rolls back the others (02 12.4 rule 6); the key of (time, id) reads a candidate that stays only once per run; a long store outage piles up no jobs.
D-M3-08-7 2026-10-23 Blocked files (complete with infected or error) follow the row "Failed or blocked" of 12 12, not the row "Never attached", and drafts do not protect them; rows with the purposes avatar and branding are not touched by the cleanup steps for never attached files and failed rows, they go with their release (M3-10); gdpr_export files belong to M7-06. 12 12 names blocked files there and their bytes are gone; principals.avatar_file_id and branding.logo_file_id, which other modules own, may still point to those rows.
D-M3-08-8 2026-10-23 The cleanup removes the entries of <file.fs.root>/.tmp whose modification time is older than file.upload_session_ttl (fs.Store.SweepTemp, not part of ObjectStore); a symbolic link is removed as an entry and never followed. With the S3 driver nothing is swept. Every write of the driver changes its temporary file with every chunk and ends with a rename or a removal, and no write outlives an upload session, so an older temporary file belongs to a crashed process; the sweep reads only .tmp, never the key space (12 4.1).
D-M3-08-9 2026-10-23 The age of a file for file.unattached_ttl and file.failed_row_ttl counts from its latest stored time: created_at, the session's completed_at, scanned_at, and expires_at of an expired session (domain.LastChange). The edge of the expiry of an open session is expires_at itself, as in M3-03 (domain.SessionPastExpiry; the brief calls it SessionExpired, which is the name of the session state constant). A file whose upload or scan took long keeps its full window after it became attachable or failed; an aborted session stores no time of its own, so its file counts at most file.upload_session_ttl early.
D-M3-08-10 2026-10-23 The draft check runs in the deleting unit of work after the file lock and locks no draft. A draft written after the check does not save the file, the same outcome as a draft written after the deletion (04 14 checks file_ids only in form and count); the send then answers 409 conflict with reason file_not_attachable.
D-M3-08-11 2026-10-23 deleteFile answers: no row 404; a deleted file 204 only for its uploader and the same 404 for everyone else, without asking authorize; otherwise authorize of file.delete (404 or 403), then 409 conflict with reason file_attached for an attached file and purpose_not_deletable for an avatar, logo or export file; the unit of work locks the row, reads deletion and attachment again and marks it deleted. No event and no audit. 06 7 answers a DELETE of a deleted resource with 204 and cannot tell a purged file from one that never existed (AZ-P4); attached files go with message.delete (05 4.8); avatar and logo have their own operations.
D-M3-08-12 2026-10-23 Every job of files treats a missing row like a deleted file: the scan pass and the image job end with nil and no write and remove the objects they wrote themselves; the code of M3-04 and M3-06 already did so, and tests with a purged row prove it. From now on a row can vanish; an internal error would retry forever (12 5.3).
D-M3-08-13 2026-10-23 New key file.gc_batch_size (chapter 12, integer, at least 1, default 500); privacy.object_delete_delay (chapter 20) is registered with the files keys; the cross-check object_delete_delay_gc fails unless file.gc_interval is shorter than privacy.object_delete_delay. 02 12.4 rule 6 asks for batches and a limit needs a named key; the catch-up must run at least once inside the delay.
D-M3-08-14 2026-10-23 The gauge bothy.file.purge.backlog (unit {file}, no attributes) is a synchronous gauge recorded at the end of every cleanup run, like bothy.file.scan.queue; it reports nothing before the first run. A Warn line with the count and no IDs appears when the oldest mark is older than privacy.object_delete_delay. The brief asks for a gauge that reads the value the cleanup stored and never queries in the callback; a synchronous gauge reports exactly the last recorded value with the existing registry helper.
D-M3-08-15 2026-10-23 No new index; the reads of the cleanup and the purge stand in tools/lint/sql_visibility.yaml with the category system_job, FileForDelete with authorize_resolution and LockFileForDelete with after_authorize, sync.HeldByUploaderDrafts with system_job. 03 4.7 names the indexes of files; expired sessions use (expires_at) WHERE state = 'open', the deleted rows are few, and the candidates of the other steps are bounded by the prefilter on created_at and the batch.
D-M3-08-16 2026-10-24 getFile and deleteFile ask authorize for an anonymous caller before they read the file, on a descriptor that names only the id (the helper anonymous of the download path, now with the action as parameter); a request without credentials gets 401 unauthenticated for a live, deleted, purged or unknown id alike. Test TestAnonymousFileByID_Unauthenticated. Both read the row first and answered an unknown or deleted id with 404 but a live one with 401, so a caller without credentials learned whether an id exists (07 4.2 step 1: anonymous is decided before anything is resolved); the contract test G7 found it through its check for ignored authentication after a 204 of deleteFile.