Versioned channel consent + current-version enrollment gate
This story builds the channel data-transit consent substrate that every
conversational-channel enrollment will pass through (RFD-AGT-013, D21 /
CC-51): a versioned consent registry, per-firm acceptance evidence, a
fail-closed enrollment gate, and an audit trail. The Meta subprocessor
gate is built now; the legal text is deferred to the first real firm
(zero clients, staging only), so the seeded v1 row carries
placeholder copy.
What problem this solves
When counsel talks to Mira over WhatsApp, matter content transits Meta —
a subprocessor the firm must affirmatively consent to before anyone
binds a number. CC-51: enrollment is refused until the firm accepts
the consent. Consent must be versioned (a text change re-gates
every firm) and auditable (who accepted which version, when). Channel
consent deliberately does not overload the existing
inference-egress consent (business.firm_inference_policies,
pramaan-docs ADR-116): different subject, different tables, different
gate.
The two tables (migration 0206)
| Table | Scope | Holds |
|---|---|---|
ops.channel_consent_versions |
Platform-global, no RLS (app role: SELECT only) | provider / purpose / version + is_current;
exactly one current version per (provider, purpose), enforced by a
partial unique index. Seeded: (meta, channel_data_transit, v1). |
business.channel_consent_acceptances |
Firm-scoped RLS (firm isolation + staff metadata + support app) | Which version the firm accepted, accepted_by_user_id,
accepted_at. Append-only: SELECT + INSERT grants, plus a
mutation-blocking trigger — consent evidence never changes. |
The gate (src/domains/channel_consent/service.py)
load_channel_consent(session, firm_id=…)— fail-closed decision read: granted only when an acceptance row points at the registry's current version. Runs under the caller's firm-scoped (RLS) transaction, exactly like the inference consent gate, so it can never read across tenants.require_channel_consent(…)— the CC-51 enrollment gate. The enrollment ceremony (T2a-2) calls this before creating any channel binding; it raisesChannelConsentRequiredError(HTTP 403) otherwise.accept_current_channel_consent(…)— records acceptance of the current version (idempotent viaON CONFLICT (firm_id, version_id) DO NOTHING) and stamps onechannel_consent.acceptedaudit event per first acceptance through the typedsrc.shared.audit.emitsingle-writer path.
Version bumps re-close the gate
When legal text lands (or changes), a migration mints v2,
flips is_current, and every firm's v1 acceptance instantly
stops satisfying the gate — enrollment is refused again until the firm
re-accepts. Both evidence rows are kept (append-only), so the audit
trail shows the full consent history.
How to verify
# Static migration pins (single head, RLS, append-only, seed, downgrade) uv run pytest tests/unit/test_channel_consent_migration.py # CC-51 lifecycle + version-bump re-gate + audit trail (Docker test DB) make test-db bash scripts/run-with-test-db-env.sh uv run pytest \ tests/integration/test_channel_consent_db.py \ tests/integration/test_channel_consent_rls.py # Migration round-trip + model drift (the CI gate) make test-db-alembic-roundtrip
Scope boundaries (TICKET-MAP v2)
- This PR ships the consent substrate T2a-1 (schema-seed, #2847) and T2a-2 (enrollment) build on: registry + acceptance tables, gate, acceptance write path, audit. No enrollment/binding tables land here — those belong to the seed.
- No HTTP routes: the acceptance endpoint arrives with the enrollment ceremony / settings UI tickets, so no OpenAPI snapshot churn.
- Legal text is a placeholder per D21;
summarycarries the copy until the first real firm.