Story-2875

chat_turn channel-origin + restriction provenance

This story is RFD-AGT-013 T2j-a — the provenance every cross-surface rule keys off, plus the deterministic enforcement of no-verbatim-restricted-reveal (D16/F8, CC-27/CC-32/ CC-37). The ticket's Why is the design: restriction policy is enforced in functions, never trusted to model behavior. It branches off the Story-2847 schema-seed with zero new migrations — the seed already shipped agents.chat_turn.channel_origin + restriction_provenance (mig 0207); this PR gives those columns their writer, their contract, and their teeth.

The three pieces

PieceWhereWhat it does
Provenance contract src/domains/channel/restriction.py One canonical shape for both columns. channel_origin is a short channel token — 'app' for socket-origin turns, else the channel name; normalize_channel_origin defaults an absent origin to 'app' and raises on garbage. restriction_provenance is a JSONB array of RestrictedSourceRef{kind: matter|document, source_id, restriction?} — the shape the sensitivity gate (Story-2853) will stamp. The parse is fail-closed: any malformed payload raises.
The writer src/clerk_worker/runner.py (_persist_converse_turns) Every persisted exchange now stamps channel_origin on BOTH rows (counsel + assistant) — 'app' when the command carries no channel origin, the channel name when the T2b-a channel wiring stamps one server-side. The assistant row alone carries restriction_provenance: provenance marks CONTENT that drew on restricted sources, and the counsel turn is counsel-authored. The payload is validated + normalized at write time; garbage fails the run closed (the Bug-2211 posture), so the column never holds a value the delivery gate cannot parse.
The enforcement src/domains/channel/delivery.py (dispatch_whatsapp_text) + enforce_no_verbatim_restricted_reveal The delivery path gates a body BEFORE any bytes cross when the caller carries the turn's provenance: non-empty (or unparseable) provenance, or a verbatim match against known restricted excerpts (case/whitespace-insensitive), means the body never crosses — the static restricted teaser (build_restricted_teaser, D20: names no firm/matter) is chunked, sent, and recorded in its place, and the block is logged. A paraphrase that quotes nothing restricted passes — judging paraphrase-vs-verbatim is the agents lane's job (T2j-b); this gate is literal and deterministic.

Where the binding and adapter live

The turn row stores the one fact the channel records cannot: the surface the exchange happened on. WHICH binding and WHICH adapter produced the exchange are already durable on the Story-2851 records, linked by chat_turn.agent_run_id — business.channel_inbound_messages (binding_id, provider message id) for the inbound half and business.channel_delivery_records (binding_id, provider, per-chunk attempts) for the outbound half. No column is duplicated; the provenance triangle closes on the run id.

Scope boundaries

How to verify

# Provenance contract + enforcement branches (pure unit)
uv run pytest tests/unit/test_channel_restriction_provenance.py

# Writer stamping: app origin, channel origin, fail-closed garbage
uv run pytest tests/unit/clerk_worker/test_runner_turn_origin.py \
  tests/unit/clerk_worker/test_runner_converse_turns.py

# Delivery gate: the sender never receives a restricted body
uv run pytest tests/unit/test_channel_delivery_restriction.py

# Full unit sweep + lint
uv run pytest tests/unit
uv run ruff check