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
| Piece | Where | What 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
- No new migration: the Story-2847 schema-seed (mig 0207, one additive nullable alter with downgrade) already owns both columns; chat_turn is not a channel-seed table, and this PR adds nothing to it.
- Not the sensitivity gate: Story-2853 owns the restricted classification of matters/documents. This PR ships the provenance SHAPE the gate stamps and the enforcement that consumes it — never the classification itself.
- No channel-turn wiring: T2b-a owns stamping
channel_origin/restriction_provenanceonto the clerk command for WhatsApp-originated launches; this PR makes the writer honor them and pins that contract with tests. - No OpenAPI churn (no route/DTO changes); no model calls anywhere (R11 — the teaser is a constant, zero LLM).
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