Model/docs/adr/0060-bulk-document-download-package-model.md
Khalim Conn-Kowlessar 6d001d650e ADR-0059/0060: Bulk Document Download design — SES-SMTP email sender + capped best-effort Download Package 🟪
Grilled 2026-07-08. Locks: app-owned-task + attach-mode lane (ADR-0055);
FastAPI route resolves the requesting user (dbId -> UserModel.email) and pins
it + the resolved property_id set into tasks.inputs; one capped Download
Package = one sub_task; landlord_property_id matching (property_id a future
gap); address-named folders (hubspot-enriched), latest per Document Type,
null-type skipped+reported; best-effort (fail only on infra / wholly-empty);
URL to sub_task.outputs + email; DATA_BUCKET/bulk-downloads/ ~7-day lifecycle.
Backend-sent email via a repositories/email port + infrastructure/email SES-SMTP
adapter. Glossary: Bulk Document Download, Download Package, Document Type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:54:43 +00:00

92 lines
5.3 KiB
Markdown

---
status: accepted (builds on ADR-0055, ADR-0059)
---
# Bulk Document Download builds one capped, best-effort Download Package per request
Users need to pull the documents held in `uploaded_files` for many properties
at once — per property, the latest file of each **Document Type** — as a single
archive, without clicking through the UI file by file. The request is initiated
in the front end, can span a hand-picked set of properties or a whole
portfolio, and finishes with a link emailed to the requester (ADR-0059).
`uploaded_files` links to a property by `uprn` **or** `landlord_property_id`
(both nullable) and has **no `property_id`** column; `file_type` (the Document
Type) is itself nullable; and files live across arbitrary buckets
(`s3_file_bucket` per row).
## Decision
**A request produces exactly one Download Package: a ZIP of one folder per
property, each holding the latest file of each Document Type — built
best-effort, size-capped at the trigger, on the app-owned-task + attach-mode
lane (ADR-0055).**
- **Trigger & lifecycle (ADR-0055).** The FastAPI route creates the `tasks`
row, resolves the selection to a concrete `property_id` set (expanding "all
properties" of a portfolio), **pins that set plus the recipient email into
`tasks.inputs`** (TEXT/JSON — a large id list is why this lives in `inputs`,
not the 256 KB SQS body), pre-creates **one** `sub_task`, and drops one SQS
message (`task_id`, `sub_task_id`). The new `applications/bulk_document_download`
Lambda runs in **attach mode**; `TaskOrchestrator` owns status + roll-up.
- **One package = one sub_task.** No fan-out: the job streams each document out
of its own `s3_file_bucket` into a single ZIP in `/tmp` and multipart-uploads
it. One artifact, one URL, one email.
- **Selection cap at the route.** Reject `> N` properties synchronously with a
legible "narrow your selection" error (property count, not a document/byte
query — cheap, no S3 on the hot path). A `/tmp` size budget inside the Lambda
is a backstop that fails the sub_task with a clear reason if a pathological
selection still overflows. `N` starts at a conservative, tunable value.
- **Matching on `landlord_property_id`.** Files are gathered by
`landlord_property_id`; the missing `property_id` on `uploaded_files` is a
**known future gap**, out of scope here. Property display info (the folder
name) is enriched from the hubspot deals data
(`repositories/hubspot_deals/`).
- **Layout.** One folder per property named by human-readable **address**
(hubspot-enriched), with `landlord_property_id` appended for uniqueness;
inside, one file per Document Type = the newest by `s3_upload_timestamp`.
Rows with a **null Document Type are skipped** and listed in the run output.
- **Best-effort failure contract.** Build from whatever resolves. Skipped
properties (no documents) and skipped null-type files are recorded in
`sub_task.outputs`. The run **fails only** on an infrastructure error
(S3/DB/zip/upload/email) or when the whole selection yields **zero
documents**. The email carries an "N properties, M documents, X skipped"
summary.
- **Delivery — both channels.** The presigned URL (60-minute expiry) and the
skip-summary are written to `sub_task.outputs` (the FE already polls task
status and can show the link) **and** emailed (ADR-0059).
- **Retention.** Packages are written to `DATA_BUCKET` under a
`bulk-downloads/` prefix with an S3 lifecycle rule expiring objects after
~7 days — longer than the 60-minute URL (so a link can be re-issued without
rebuilding) but short enough that bundles never accumulate.
## Considered options
- **Fan out into per-batch sub_tasks / multiple ZIPs** (like the modelling
run). Rejected: the user would receive N links / N emails and "the download"
would stop being one file; the capped-single-package model keeps the artifact
and the notification singular.
- **Cap on resolved document count / bytes.** Rejected for v1: it adds
synchronous DB + S3 HEAD work to the trigger and couples the route to the
packaging logic; a property-count cap is cheap and predictable.
- **Strict "all-or-nothing" packaging.** Rejected: one unreadable row or a
property with no documents would deny the entire package; best-effort +
reporting matches "include documents and properties where they exist".
- **Match on `uprn`.** Deferred: `landlord_property_id` is the agreed key and
hubspot enrichment is keyed to the deal/property; revisit if/when the
`property_id` gap on `uploaded_files` is closed.
## Consequences
- New DDD pieces: `applications/bulk_document_download/` (thin handler +
trigger body), `orchestration/bulk_document_download_orchestrator.py`,
packaging rules in `domain/`, a `landlord_property_id`-keyed
"latest-per-Document-Type" query on the uploaded-file repository, a
`generate_presigned_url` + multipart upload on `S3Client`, and the email
port/adapter from ADR-0059. The only `backend/` touch is the trigger route.
- The stored, pinned `property_id` set makes a run reproducible even if the
portfolio changes between trigger and execution.
- `N`, the `/tmp` size budget, and the 7-day lifecycle are tunable knobs, not
load-bearing invariants.
- Closing the `uploaded_files.property_id` gap later would let matching move off
`landlord_property_id` without changing the package model.