diff --git a/CONTEXT.md b/CONTEXT.md
index 33bf900a..de164f7b 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -112,6 +112,16 @@ _Avoid_: job, batch, trigger request, modelling task (the pipeline's execution r
The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend).
_Avoid_: property query, segment, search (the postcode-search journey is unrelated)
+### Live projects
+
+**Project**:
+A delivery programme within a Portfolio — a batch of Properties taken through retrofit together — identified by its HubSpot **project code** (`hubspot_deal_data.project_code`; a stable **project id** will replace the code once the HubSpot ops board is settled, so the code is the *current* identifier, not the essence). A Property whose deal carries no project code sits outside every Project (the "Unknown Project" grouping) and cannot be reached by selecting Projects.
+_Avoid_: programme, scheme, deal group, batch (a **Batch** is a separate per-deal field)
+
+**Bulk document download**:
+An app-triggered, asynchronous assembly of a single ZIP of the documents held against a chosen set of Properties — picked as a row selection in the Documents tab, scoped by the tab's current Project/group — delivered as a time-limited link emailed to the requester (and, within the session, retrievable in-app). A selection that matches no documents produces no ZIP: it fails rather than emailing an empty archive. Properties are matched to their documents by **landlord property id**. See [ADR-0011](./docs/adr/0011-app-owned-task-marker-in-task-source.md).
+_Avoid_: bulk export, document export, zip download
+
## Lifecycle
A **BulkUpload** moves through these statuses:
diff --git a/docs/adr/0011-app-owned-task-marker-in-task-source.md b/docs/adr/0011-app-owned-task-marker-in-task-source.md
new file mode 100644
index 00000000..01534378
--- /dev/null
+++ b/docs/adr/0011-app-owned-task-marker-in-task-source.md
@@ -0,0 +1,46 @@
+# 11. App-owned, backend-executed tasks carry their marker in `task_source`
+
+Date: 2026-07-08
+
+## Status
+
+Accepted
+
+## Context
+
+Bulk document download follows the app-created-task convention (ADR-0008): Next.js
+inserts a `tasks` row whose `inputs` column carries the selection config, then hands
+the backend only `{ task_id }`. Unlike a Modelling run — where the distributor
+receives the whole config in the dispatch payload and never looks at the task again —
+the document worker gets *only* the id, re-reads the `tasks` row, and resolves the
+selection from `inputs`. It therefore needs a durable, in-row way to recognise "this
+is a bulk-document-download task I'm allowed to act on" before it assembles anything.
+
+Modelling runs put that marker in `service` (`modelling_run`) and leave `task_source`
+human-readable (`"Modelling run – 2 scenarios"`). Reusing `service` here would work,
+but it invites a future engineer to "unify" the two conventions and move the marker —
+silently breaking the worker's guard.
+
+## Decision
+
+- **The marker is `task_source = "app:bulk_document_download"`** — a single, stable
+ string the worker guards on. The `app:` prefix denotes an app-created task (as
+ opposed to a backend-created one). It is engineer-facing only (Settings → Logs), so
+ a machine-shaped string is acceptable; no friendly-label mapping is added.
+- **`service` is left null.** For a task whose marker already lives in `task_source`,
+ a second marker in `service` is redundant and can drift. In-app status polling
+ scopes by `id = task_id AND source_id = AND task_source =
+ "app:bulk_document_download"`.
+- **Portfolio attachment is unchanged and uniform:** `source = "portfolio_id"`,
+ `source_id = `. This is what the Portfolio Logs page filters on, so
+ the run shows up there for free — the same as every other app-owned task.
+
+## Consequences
+
+- Do **not** move this marker into `service` to match Modelling runs. The asymmetry is
+ deliberate: the marker lives where the executor reads it — `service` for a
+ payload-fed distributor, `task_source` for a task-re-reading worker.
+- A large selection never travels in an HTTP body: it lives in `inputs`, and the only
+ thing dispatched is `task_id` (ADR-0008 lineage).
+- Future app-owned, backend-executed tasks should follow this shape:
+ `task_source = "app:"`, config in `inputs`, `source`/`source_id` for scoping.
diff --git a/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts
new file mode 100644
index 00000000..fef05d07
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { getBulkDownloadStatus } from "@/lib/bulkDocumentDownload/server";
+
+// GET — status of a triggered bulk download, for in-app polling. Reads the
+// worker's sub_task.outputs (presigned URL, included count, skipped list).
+export async function GET(
+ _request: NextRequest,
+ props: { params: Promise<{ portfolioId: string; taskId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ const { portfolioId, taskId } = await props.params;
+ if (!/^\d+$/.test(portfolioId)) {
+ return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
+ }
+
+ try {
+ const status = await getBulkDownloadStatus(portfolioId, taskId);
+ if (!status) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ return NextResponse.json(status);
+ } catch (err) {
+ console.error("GET /bulk-document-download/[taskId] error:", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts
new file mode 100644
index 00000000..564206df
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts
@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { z } from "zod";
+import { triggerBulkDocumentDownload } from "@/lib/bulkDocumentDownload/server";
+
+// The document service resolves the selection and creates its sub_task
+// synchronously before returning 202, which can outrun the default function
+// budget on large projects. Give the round-trip headroom so the browser gets
+// our JSON (202 / mapped error) rather than a platform 504.
+export const maxDuration = 60;
+
+const scopeSchema = z.union([
+ z.object({ kind: z.literal("project"), projectCode: z.string().min(1) }),
+ z.object({
+ kind: z.literal("all-projects"),
+ projectCodes: z.array(z.string().min(1)),
+ }),
+]);
+
+const bodySchema = z.object({
+ scope: scopeSchema,
+ includeAll: z.boolean(),
+ landlordPropertyIds: z.array(z.string().min(1)).default([]),
+});
+
+function sessionToken(request: NextRequest): string | undefined {
+ return (
+ request.cookies.get("__Secure-next-auth.session-token")?.value ??
+ request.cookies.get("next-auth.session-token")?.value
+ );
+}
+
+// POST — record + dispatch a bulk document download (ADR-0008 convention)
+export async function POST(
+ request: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ const { portfolioId } = await props.params;
+ if (!/^\d+$/.test(portfolioId)) {
+ return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
+ }
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await request.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ try {
+ const outcome = await triggerBulkDocumentDownload({
+ selection: {
+ scope: body.scope,
+ includeAll: body.includeAll,
+ landlordPropertyIds: body.landlordPropertyIds,
+ portfolioId: Number(portfolioId),
+ },
+ sessionToken: sessionToken(request),
+ });
+ switch (outcome.kind) {
+ case "ok":
+ return NextResponse.json({ taskId: outcome.taskId }, { status: 202 });
+ case "invalid_selection":
+ return NextResponse.json({ error: outcome.message }, { status: 400 });
+ case "backend_error":
+ // Pass the backend's status (400 over-cap / 409 already-started / 5xx)
+ // and its detail message straight through.
+ return NextResponse.json(
+ { error: outcome.message },
+ { status: outcome.status },
+ );
+ }
+ } catch (err) {
+ console.error("POST /bulk-document-download error:", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx
new file mode 100644
index 00000000..d8b174e8
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx
@@ -0,0 +1,360 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ PackageCheck,
+ AlertTriangle,
+ X,
+ Download,
+ Loader2,
+ CheckCircle2,
+} from "lucide-react";
+import DocumentTable, { type DocumentSelection } from "./DocumentTable";
+import type {
+ ClassifiedDeal,
+ DocStatusMap,
+ PortfolioCapabilityType,
+ ApprovalsByDeal,
+} from "./types";
+import {
+ MAX_BULK_DOWNLOAD_PROPERTIES,
+ buildSelectionConfig,
+ bulkDownloadRefetchInterval,
+ type BulkDownloadStatus,
+} from "@/lib/bulkDocumentDownload/model";
+
+interface Props {
+ data: ClassifiedDeal[];
+ onOpenDrawer: (
+ dealId: string,
+ uprn: string | null,
+ landlordPropertyId: string | null,
+ dealname: string | null,
+ batch: string | null,
+ batchDescription: string | null,
+ ) => void;
+ docStatusMap: DocStatusMap;
+ portfolioId: string;
+ userCapability: PortfolioCapabilityType;
+ approvalsByDeal?: ApprovalsByDeal;
+ /** Current Documents-tab scope — a real project code or the "__ALL__" sentinel. */
+ currentProjectCode: string;
+}
+
+/** JSON body or null — never throws, so a 504's HTML page can't crash the UI. */
+async function parseBody(res: Response): Promise<{ error?: string; taskId?: string } | null> {
+ const text = await res.text().catch(() => "");
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Bulk document download on the Documents tab: a "Bulk download" button flips the
+ * table into row-selection mode; the picked landlord property ids become a task
+ * whose ZIP is emailed (and shown here while the page stays open). Scoped by the
+ * tab's existing project/group selection.
+ */
+export default function DocumentBulkDownload({
+ data,
+ onOpenDrawer,
+ docStatusMap,
+ portfolioId,
+ userCapability,
+ approvalsByDeal,
+ currentProjectCode,
+}: Props) {
+ const [selecting, setSelecting] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [done, setDone] = useState<{ taskId: string } | null>(null);
+
+ const landlordPropertyIds = [...selectedIds];
+ const built = buildSelectionConfig({
+ scope: { kind: "project", projectCode: currentProjectCode },
+ includeAll: false,
+ landlordPropertyIds,
+ portfolioId: Number(portfolioId),
+ });
+
+ const trigger = useMutation<{ taskId: string }, Error, void>({
+ mutationFn: async () => {
+ const res = await fetch(`/api/portfolio/${portfolioId}/bulk-document-download`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ scope: { kind: "project", projectCode: currentProjectCode },
+ includeAll: false,
+ landlordPropertyIds,
+ }),
+ });
+ const body = await parseBody(res);
+ if (!res.ok) {
+ throw new Error(
+ body?.error ??
+ (res.status >= 500
+ ? "The server didn’t respond in time. Your download may still be preparing — check your email shortly."
+ : `Couldn’t start the download (${res.status}).`),
+ );
+ }
+ if (!body?.taskId) throw new Error("Unexpected response from the server.");
+ return { taskId: body.taskId };
+ },
+ onSuccess: ({ taskId }) => {
+ setDone({ taskId });
+ setSelecting(false);
+ setSelectedIds(new Set());
+ },
+ });
+
+ const status = useQuery({
+ queryKey: ["bulk-download-status", portfolioId, done?.taskId],
+ enabled: !!done,
+ refetchInterval: (d) => bulkDownloadRefetchInterval(d),
+ queryFn: async () => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/bulk-document-download/${done!.taskId}`,
+ );
+ const body = await parseBody(res);
+ if (!res.ok || !body) {
+ throw new Error("Couldn’t check the download status.");
+ }
+ return body as unknown as BulkDownloadStatus;
+ },
+ });
+
+ const selection: DocumentSelection | undefined = selecting
+ ? {
+ selectedIds,
+ onToggle: (id) =>
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ }),
+ onSetMany: (ids, on) =>
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ for (const id of ids) {
+ if (on) next.add(id);
+ else next.delete(id);
+ }
+ return next;
+ }),
+ }
+ : undefined;
+
+ const overCap = selectedIds.size > MAX_BULK_DOWNLOAD_PROPERTIES;
+
+ return (
+
+ {/* Delivery / status card */}
+ {done && (
+ setDone(null)}
+ />
+ )}
+
+ {/* Bulk-download control bar */}
+ {!selecting ? (
+
+
+
+ ) : (
+
+
+ {selectedIds.size} selected
+
+
+ Tick properties, or use the header box to select the page (then “select
+ all across pages”).
+
+ {overCap && (
+
+ Over the {MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection.
+
+ )}
+ {trigger.isError && (
+ {trigger.error.message}
+ )}
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+function DeliveryCard({
+ status,
+ isError,
+ onDismiss,
+}: {
+ status: BulkDownloadStatus | undefined;
+ isError: boolean;
+ onDismiss: () => void;
+}) {
+ const [showSkipped, setShowSkipped] = useState(false);
+ const state = status?.state ?? "preparing";
+
+ // Completed, but the link couldn't be surfaced in-app (unparseable outputs).
+ // The worker still emails it — send the user there rather than hang.
+ if (state === "ready" && !status?.delivery) {
+ return (
+
+
+
+
Your download is ready
+
+ We’ve emailed you the link — it’s valid for about 60 minutes.
+