Story-2847

SCHEMA-SEED: channel tables + ORM + RLS + indexes

This story lands every conversational-channel table in one migration (RFD-AGT-013, TICKET-MAP v2 T2a-1 — the W2 unblocker). pramaan-functions has 206+ migrations on one linear chain; N behavior tickets each adding tables would collide as parallel alembic heads at merge. The seed kills that: every W2 behavior ticket branches off this schema with zero new migrations where possible. The consent tables are Story-2846's (0206) and are deliberately not recreated here — 0207 chains directly onto 0206.

What problem this solves

Wave 2 (WhatsApp plug) needs durable persistence for identity bindings, enrollment, delivery, fetch links, refusals, dedupe, confirmations, and revocation before any behavior can be built. Landing the schema first, in one PR, means T2a-2/T2a-3/T2d-a/T2e-a/T2g/T2j-a/T3b-1/T3d-b write only behavior code — no migration races, no multi-head bomb, no rebase-at-merge choreography for the common case.

The nine tables (migration 0207, all business + firm-scoped RLS)

TableHoldsKey constraints / indexes
channel_identities Per-firm binding lifecycle (D4/D7): provider subject → user in one firm; status, verified/bound/revoked stamps, re-attestation. Partial unique one active binding per (firm, provider, subject); hot subject-lookup index; one-firm removal revokes only that firm's row (CC-23).
channel_enrollment_codes Hashed single-use enrollment codes (D7): expiry, consume, replay counters. Unique code_hash (SHA-256 only, never plaintext); status machine pending/consumed/expired/revoked.
channel_sticky_context Firm-scoped sticky disambiguation (D9/D16): last matter/session per binding per firm. Unique (firm, identity) — one-firm removal kills only that firm's context.
channel_delivery_records Durable outbound delivery (T2d-a): run/binding/thread keyed, provider message ids, attempts, callback stamps, template-window metadata, errors. Partial unique (provider, message id) → idempotent callbacks; firm+run, firm+status (retry sweep), binding+created indexes.
channel_fetch_tokens Hashed wrapper tokens (D18): firm/matter/document/binding/session linkage, TTL, multi-open use-count (CC-21), revocation. Unique token_hash; firm+binding and firm+document indexes.
channel_refusal_ledger Refused-sender attempts (D8/D19): zero-amplification backoff and zero-firm terminal-notice accounting. Nullable firm (zero-firm senders = staff-visible only); subject+attempted and firm+attempted window indexes.
channel_inbound_messages Provider-message dedupe (CC-15) + receipt traceparent for the webhook→brain trace. Unique (provider, message id) → redelivery is idempotent.
channel_pending_confirmations Per-document ingestion confirmation (D11): independently resolved firm/matter, 10-min expiry, checksum + S3 object ref. Partial pending-per-binding index; firm+status+expiry sweep index. Multi-pending rejection is behavior (T3d-b).
channel_revocation_epochs Per-(firm, user) revocation high-water mark (T2e-a): artifacts stamped before revoked_before are dead. Composite PK (firm, user); user index for the remote-logout sweep (CC-45).

Plus agents.chat_turn provenance (T2j-a)

Two hand-written columns on the agents-schema chat_turn table: channel_origin (NULL/'app' = socket origin) and restriction_provenance (JSONB restricted-source refs driving no-verbatim-restricted-reveal on channels). The runtime Core table in src/shared/agents_db_models.py is updated in the same PR; the agents schema sits outside autogenerate compare by design.

RLS + access model

How to verify

# Static migration pins (single head on 0206, RLS, hashes, downgrade)
uv run pytest tests/unit/test_channel_schema_seed_migration.py

# RLS isolation per table — firm A cannot see/write firm B (Docker test DB)
make test-db
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/integration/test_channel_schema_seed_rls.py

# Round-trip + model drift (the CI gate)
make test-db-alembic-roundtrip
bash scripts/run-with-test-db-env.sh uv run alembic check

Scope boundaries (TICKET-MAP v2)