- **Bounded the memory footprint of `DocumentLifecycleService.bulk_soft_delete_documents`** (`opencontractserver/corpuses/services/lifecycle.py:430-657`), the shared bulk-trash primitive behind `empty_corpus` and `FolderCRUDService._trash_documents_in_subtree` (folder cascade-delete). Previously the primitive materialized every active, locked `DocumentPath` (plus its joined `Document`) for the whole document set into a single Python `list(...)` inside one `transaction.atomic()` block — for a multi-thousand-document corpus this risked an app-server heap spike/OOM rather than a bounded DB timeout (follow-up to #2030/#1951). It now processes active paths via keyset ("seek") pagination in fixed-size chunks (`BULK_SOFT_DELETE_CHUNK_SIZE = 500`, new `opencontractserver/constants/corpus_lifecycle.py`), so at most one chunk's worth of rows is ever held in memory. The `pk`-ascending lock-acquisition order (deadlock hygiene) is preserved *across* chunks via a strictly-increasing `pk__gt` floor, not just within one chunk; the `post_save` signal replay still fires once per created row, once per chunk; the whole operation still runs inside a single `transaction.atomic()` (unchanged all-or-nothing semantics); and the `is_public` revocation tail still runs once over the full accumulated document-id set (it was already a fixed, cheap query pair, so chunking it would only add query count without reducing memory). Trade-off: query count is now `O(document_count / BULK_SOFT_DELETE_CHUNK_SIZE)` instead of a fixed handful regardless of size — a deliberate, documented exchange of a few extra statements for a hard memory ceiling. `opencontractserver/tests/test_corpus_objs_service.py::TestBulkSoftDeletePrimitive::test_query_count_independent_of_document_count` was rewritten into `test_query_count_constant_within_a_single_chunk` (per-chunk constant-cost, scoped correctly now that chunking exists) plus a new `test_query_count_increment_crossing_chunk_boundary_is_bounded_not_per_document` (proves crossing a chunk boundary adds a bounded increment, not a per-document one) and a new `test_chunked_processing_is_correct_across_multiple_chunks` (8 documents across 3 patched-small chunks, asserting full correctness of the final state: all docs trashed, restorable via the trash listing, signals fired once each, `is_public` revoked).
