Wrapper-token mint + session+matter-gated open
RFD-AGT-013 T3b-1 / D18 (founder Q1). When counsel asks Mira for a
document on WhatsApp, the brain never mints a URL — it resolves the
document (Story-2855), and the broker issues a wrapper link on
PRAMAAN's own domain: https://download.in.pramaan.click/<token>,
alive ~24 hours. Opening it requires a normal PRAMAAN login
plus the opener's own matter access; every open — the first or
the hundredth — is audit-logged. Phone possession alone gets
conversation, never documents.
Zero new migrations — the store was seeded
business.channel_fetch_tokens landed in the Story-2847
schema seed (migration 0207) with firm-scoped RLS, exactly so every W3
behavior ticket ships with no parallel alembic head. This story adds
only code: the mint/open service, the serving Lambda, audit vocabulary,
and three settings.
Mint — gate first, hash at rest
mint_fetch_link (src/domains/channel/fetch_link.py)
runs on the caller's firm-scoped RLS session, in this order:
- Sensitivity gate first (CC-37): Story-2853's
gate_fetch_link_mintdecides before anything else; a restricted or unresolvable document mints nothing — the caller gets the static teaser to send instead. - Binding verified, never trusted: the
binding_idmust be an active binding of the session's firm+user (the Story-2855 precedent). - Uniform NotFound (CC-20): a document outside the caller's RLS, outside the named matter, or missing reads identically.
- Hash-only store (D18): 256-bit
secrets.token_urlsafeplaintext appears once — in the returned URL; the row holds its SHA-256 hex. TTL fromCHANNEL_FETCH_TOKEN_TTL_SECONDS(~24h). The base URL comes from the/pramaan/{env}/download/base-urlSSM registry entry (Chore-2852), never a hardcoded host. - Audited: one
fetch_link.mintedrow, matter+document scoped.
Open — the D18 gate, per open
open_fetch_link is the data path of the new
src/download_open/handler.py Lambda, deployed over the
Chore-2852 shell (pramaan-download-staging, auth-NONE at
the gateway by design — the gate is handler logic). Per open:
- Session: Clerk JWT (Bearer header or the browser's
__sessioncookie), verified networklessly against the pinned instance public key (CLERK_JWT_VERIFICATION_KEY) — the function sits in the no-NAT VPC to reach the token store, so JWKS is unreachable and NAT is never the answer (ADR-167). No credential → 401. - Token liveness: hash lookup, expiry, sweep revocation, and the issuing binding's revocation epoch (remote logout / unbind / offboard kill outstanding links even on a sweep miss — CC-24/CC-40/CC-45).
- The opener's own access (CC-39): active membership
in the token's firm, then the matter read under record-level RLS
(
business.can_read_matter) on a session scoped to the opener — a link forwarded to someone without access dies here. The token is bound to matter + channel-identity record, never identity-bound (A-13). - Restricted-after-mint recheck: a privilege flip after mint kills the outstanding link, fail-closed.
- Serve: Story-2861 now stamps every page with the opener identity + exact UTC time, stores a unique transient PDF, and returns a minutes-lived presigned GET for that object — the 24h life belongs to the wrapper, never to S3 (CC-36/CC-38).
- Every open audited (CC-21/CC-36): multi-open
stamps
use_count+ opened-at watermarks and onefetch_link.openedrow per open; every denial of a real token landsfetch_link.access_deniedwith an internal reason. On the wire, every denial is one uniform404 {"error":"link not found"}(CC-10/CC-11/CC-31).
Deploy + proof
| Check | Where |
|---|---|
| CC-37 restricted mints nothing; hash-only at rest; mint audited | tests/integration/test_channel_fetch_link.py |
| CC-19/21/36 multi-open: 2 opens → 2 unique watermarked PDFs + use_count=2 + 2 audit rows | tests/integration/test_channel_fetch_link.py |
| CC-39 denials: no matter access / non-member; expiry, revocation, epoch each kill the link, all audited | tests/integration/test_channel_fetch_link.py |
| Wire contract: 401 / uniform 404 / 302 no-store / canary sentinel | tests/unit/test_download_open_handler.py |
| Token entropy + hash form + base-URL registry resolution | tests/unit/test_channel_fetch_link_helpers.py |
Deploy contract: code-only, no aws iam, canary pinned to the handler sentinel (R4/R6) |
tests/unit/test_download_open_deploy_workflow.py |
The deploy (.github/workflows/deploy-download-open.yml)
updates code on the Terraform shell only. Its canary drives the real
cold-start dependency set — the ADR-162 DB SecureString read, the
base-url registry read, the pinned Clerk key, and Story-2861's transient
watermark S3 round trip — and goes RED while the relevant pramaan-infra
twin is unapplied: the R2 contract, observed red before it is believed
green. The G5b W3 LIVE probe runs after that twin lands.