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 criterion | Proof |
|---|---|
| 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.