Storage and the workspace quota

What counts toward the limit, what is checked, what is cleaned up

Every file a workspace keeps in object storage has one row in stored_objects: its key, its workspace, its size as the server measured it, and what kind of file it is. A workspace's usage is the sum of those rows (staged ones excepted, below), and it is checked against the plan's max_storage_gb (StorageQuotaService.assert_storage_available).

services/stored_objects.py is the only thing that writes the table:

CallWhen
record(...)The server has just written the bytes, and knows their length
record_from_storage(...)A browser or device uploaded straight to storage through a presigned URL; the size is read from storage
claim(keys)A staged upload is attached to what it was uploaded for
remove(keys)Delete the objects and their rows together
remove_after_commit(keys)The same, for deletes inside someone else's transaction: rows now, objects once it commits
key_in_use(key)Whether any record still points at an object; asked before deleting a file other rows may share

A key is counted once however many rows refer to it (a ticket upload mirrored onto its task, a duplicated document's attachments, a form upload that became a resume).

What is counted#

KindWritten atChecked before upload
driveDrive uploadyes
task_attachmenttask attachment uploadyes
compliance_documentdirect upload, or registration after a presigned upload (sized from storage; the key must be the workspace's)yes (presign: the declared size)
docx_versionevery saved version of a Word documentyes, including duplicate and version restore
document_attachmentdocument attachment uploadyes
import_archive, import_attachmentConfluence/Notion import, and the files it extractsyes (the archive)
chat_filechat uploadyes
ticket_attachmentfiles added by agents or membersyes
public_form_uploadpublic form file field, staged until the form is submittednever refused
assessment_resumeresume on an assessment's shareable link, staged until keptnever refused
assessment_answera candidate's file answernever refused
assessment_recordingwebcam and screen recordings, sized from storage on completionnever refused
tracker_evidencedesktop tracker screenshots, sized from storage when the event arrivesyes (the declared size)

People outside the workspace (candidates, form submitters, ticket requesters) are never refused because of the workspace's plan. Their files count all the same — except a staged upload (public_form_upload, assessment_resume), which counts only once it is claimed. Anyone with a public form or a shareable link can stage one without signing in; counted at once, a stranger could push the workspace past its limit and have its own team's uploads refused until the cleanup removed what was never submitted.

What is removed#

  • Staged uploads nothing claimed within a day, import archives once the import completes (a week after a partial or failed one, for the retry), and multipart uploads abandoned for a day: the cleanup_stored_objects activity, hourly (stored-object-cleanup). Before removing a staged upload it checks key_in_use, and claims it instead if something did attach it. An abandoned multipart upload (a candidate who reloaded mid-recording) is not an object, so neither the ledger nor a listing sees it; aborting it is what frees its parts.
  • Word history: each document keeps the newest keep_versions saves (workspace setting, Settings → Docs → Word version history, default 20, 1 to 200). Older versions and their files are deleted as new ones are saved; pinned and labelled versions are always kept and do not count toward it. Purging a trashed document removes every version's file.
  • Drive: deleting a file deletes its bytes. There is no Drive trash to restore from, so a deleted file used to stay stored and uncounted.
  • Tickets: deleting a ticket deletes its files and its replies' files, unless the linked task or anything else still holds them.
  • Recordings: Delete recording on a candidate's proctoring tab, workspace admins only. The proctoring events and trust score stay; a recording whose file could not be deleted keeps its link.

Not removed: compliance documents marked deleted (kept, and counted, as before), chat files (no record says which message holds which file), and archived tasks (they can be restored).

Backfill#

migrate_2026_09_28a_stored_objects.sql fills the table from every table that records a key and a size. Then list the bucket to add what nothing records and correct sizes a browser reported:

docker exec aexy-backend python scripts/backfill_stored_objects.py --dry-run
docker exec aexy-backend python scripts/backfill_stored_objects.py

--prune-missing forgets rows whose object is gone. --purge-deleted-drive deletes the files of Drive items deleted before deleting freed storage.