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)
| Table | Holds | Key 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
- Every table:
ENABLE+FORCE ROW LEVEL SECURITY,firm_isolation_policy(ALL) +staff_metadata_policy(SELECT) — the 0206 pattern. - Grants are least-privilege per table: the refusal ledger is SELECT+INSERT only; sticky context is the only table with DELETE (derived ephemeral state, killed on unbind); nothing else deletes.
- Every table and non-obvious column carries a comment tying it to the RFD decision it implements.
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)
- Schema + ORM + RLS only: no behavior, services, endpoints, or workers — those are the W2 behavior tickets that branch off this seed.
- Consent tables belong to Story-2846 (0206); this migration chains onto it and touches nothing of it.
- No OpenAPI churn (no route/DTO changes), so no snapshot regen.