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
| Scenario | Observed 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
- The public-route integration detector first failed with 404 while the acceptance service had no production caller.
- With the firm-admin dependency deliberately removed, both member and editor tests failed because the route returned 200 and wrote consent. Restoring the existing role dependency makes both return 403.
- The tests exercise the real FastAPI, PostgreSQL, transaction, RLS, audit, and enrollment-gate path.
- The missing-user detector first failed with the old typed 401. It now proves lazy mirror, acceptance, enrollment, drift visibility, and later webhook canonicalization.
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.