Story-2861 · EPIC-2834 W3

Every authorized open gets its own traceable PDF

Story-2859 established the wrapper-token gate. This story changes only what happens after that gate succeeds: PRAMAAN no longer redirects an opener to the source object. It creates a unique PDF whose every page names the opener and the exact UTC open time, then redirects to that transient object with the existing short-lived S3 URL.

The serving path

verified Clerk session + live wrapper token + opener's matter access
    │
    ├─ read the authorized source PDF
    ├─ stamp every page: "Opened by <email>" + "Opened at <UTC timestamp>"
    ├─ PutObject:
    │    watermarked/firms/<firm>/matters/<matter>/
    │    documents/<document>/opens/<unique id>.pdf
    └─ 302 to a presigned GET (DOCUMENT_PRESIGN_TTL_SECONDS)

The identity is the verified opener's PRAMAAN email, not channel metadata supplied by a sender; non-ASCII code points use a reversible escaped form because PDF's standard Helvetica font is ASCII-only. The timestamp is the same UTC instant used to stamp the token's open state; the audit row records that open and is created immediately afterward. A random object suffix ensures two opens never share a destination object. PDFium sees only malware-clean bytes, and source selection uses the scanner-detected MIME rather than the upload-declared type.

Storage is deliberately transient

DOWNLOAD_WATERMARK_PREFIX selects the lifecycle-owned prefix; it defaults to watermarked. Redirect lifetime reuses the existing bounded DOCUMENT_PRESIGN_TTL_SECONDS setting (default 900 seconds), so there is no second URL-TTL truth. Chore-2927 and pramaan-infra PR #205 supplied the transient s3:PutObject/kms:GenerateDataKey grant and lifecycle expiry in Terraform.

R2 capability check

The download-open deploy canary transforms a valid tiny PDF, writes it below the configured prefix, and reads the exact bytes back. Missing s3:PutObject, destination s3:GetObject, kms:GenerateDataKey/kms:Decrypt, bucket, or prefix configuration fails the deploy before a live open. The fixed canary object is overwritten and removed by the same lifecycle rule as user watermarks.

Acceptance proof

Acceptance criterionProof
CC-36: identity + exact timestamp on every page tests/unit/test_channel_watermark.py
Every open produces distinct served bytes and object URL tests/integration/test_channel_fetch_link.py
Existing session, matter access, revocation, audit, and 302 gate remains intact tests/integration/test_channel_fetch_link.py and tests/unit/test_download_open_handler.py
R2 check goes red when PutObject is denied tests/unit/test_download_open_handler.py
Deploy requires live capability evidence tests/unit/test_download_open_deploy_workflow.py

Staging proof boundary

The functions implementation and planted-negative capability test are locally proven, and the transient write/lifecycle grant landed in pramaan-infra PR #205 (closing Chore-2927). The remaining live proof is to deploy this handler, after the download shell/read configuration lands, run the non-vacuous canary, then complete UAT M-03 with two opens of one wrapper link and inspect both PDFs.