Story-2971 · EPIC-2834 M-01

The channel-consent gate now has a door

Enrollment already refused firms that had not accepted the current channel data-transit consent version. The acceptance service also already wrote append-only evidence and one audit event. The missing piece was a caller: no firm administrator could reach it.

The route

POST /app/channels/consent/accept
        │
        ├─ Clerk sub has no identity.users mirror
        │      → verify signed org_id + org_role claims
        │      → canonical webhook user/membership upserts
        │      → provisional membership with Clerk ID pending
        │
        ├─ active firm role is not firm_admin
        │      → 403 pramaan.forbidden
        │
        └─ firm_admin
               → accept_current_channel_consent(...)
               → append acceptance once
               → append channel_consent.accepted audit once
               → return the current decision

POST /app/channels/enrollment-codes
        └─ opens only while that accepted version remains current

Acceptance behavior

ScenarioObserved result
Firm admin accepts the current version 200 with granted=true, version, accepting user, and timestamp; enrollment-code mint then returns 201.
Firm admin accepts the same version again The same decision returns; the database still has one acceptance and one channel_consent.accepted audit event.
Firm member or editor calls the route 403, including when the caller presents settings:write; no acceptance row is written.
The Clerk user has no identity.users mirror row Verified org_id and org_role claims create the user and a provisional firm-admin membership through the same upserts as the Clerk webhook. Bogus claims return a typed 403 and write nothing.
The registry moves from the accepted version to a new current version Enrollment returns 403 pramaan.channel_consent.required until the new version is accepted.

How delivery-luck is removed

API Gateway has already verified the Clerk session JWT. For this one acceptance door, its active organization claims may seed the same user and membership rows the webhook seeds. The provisional membership keeps a null clerk_membership_id; reconciliation surfaces that sync gap, and a later organization-membership webhook fills the canonical ID and authoritative role on the same row using the firm-and-user key. A direct bearer fallback must pass the existing pinned-key signature, issuer, and audience verifier before it can provision.

Red-first proof

Capability contract

This route adds no cloud capability, IAM grant, queue, network path, or deploy workflow. Migration 0215 makes only the Clerk membership ID nullable; the existing firm-and-user uniqueness, RLS, webhook upserts, drift detector, consent tables, and audit writer remain authoritative.