Story-2853

Sensitivity gate — restricted = teaser + app-only at mint time

This story is RFD-AGT-013 T3c (D20, scenarios CC-27 / CC-37, findings F2/F14). Restricted matters and documents never cross a channel: on any external channel they get a static teaser plus "open the app" — enforced at mint/launch time, never after content has been produced. T3c lands before any wrapper-token minting (TICKET-MAP: T3c precedes T3b — never mint what mustn't cross). It branches off the Story-2847 schema-seed with zero new migrations.

The gate contract — manifest-driven, not a schema free-for-all

There is no restriction column on matters, and none was added. The v1 restriction signals are declared once in SENSITIVITY_MANIFEST (src/domains/channel/sensitivity.py) and read by two resolvers — extending the gate means extending the manifest, never sprinkling ad-hoc sensitivity reads across the schema:

EntityRestricted iffUnresolvable
Document documents.documents.privilege_flag is true (the FN-PRIVILEGE v2 membrane tag, Story-2499) UNKNOWN → treated as restricted (fail-closed)
Matter D20's matter-level fallback: the matter holds at least one restricted document UNKNOWN → treated as restricted (fail-closed)

Fail-closed on BOTH axes: an UNKNOWN restriction state is treated as restricted, and the channel axis recognizes exactly one unrestricted channel — "app". Any other channel string, including one the manifest has never heard of, is external. Deterministic throughout: no model call, no per-request variance; the teasers are static constants in the same shape as the T2a-4/T2f-a static refusals and carry no matter/document content.

The two enforcement seams

SeamScenarioWhere
Launch time — gate_matter_channel_turn CC-27: a restricted matter never crosses the channel Wired into prepare_launch (src/app_api/agent_launch_core.py) — the one choke point every run launch rides. The gate runs FIRST in the admission chain: a channel-originated launch against a restricted matter comes back with the teaser rejection and a blocked run row (R5 honest terminal state, stop_reason = sensitivity_gate_restricted) — never enqueued, never counted against the rate/circuit perimeter.
Mint time — gate_fetch_link_mint CC-37: a restricted document is never linked on a channel The function T3b-1 (wrapper-token minting) MUST call before inserting into business.channel_fetch_tokens. This PR ships the gate ahead of the minter; the BDD steps prove the exact call shape (gate first, mint only on allow — a gated fetch leaves zero token rows).

The app channel short-circuits WITHOUT a database read, so app launches pay zero gate cost. The HTTP adapter (src/app_api/router_agents.py) declares channel="app" explicitly; future channel-ingress consumers pass their provider.

How to verify

# Pure decision table: app never restricted; external allows only
# KNOWN-unrestricted; fail-closed on unknown restriction AND unknown
# channel; teasers are static constants naming the app
uv run pytest tests/unit/test_channel_sensitivity_gate_decision.py

# CC-27/CC-37 red-first pytest-bdd (8 scenarios: restricted/unrestricted ×
# channel/app + both fail-closed legs) against real Postgres with RLS
make test-db
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/steps/test_channel_sensitivity_gate.py

# Red-first proof: both suites were observed failing before implementation
# (ModuleNotFoundError: src.domains.channel.sensitivity), then green.

Scope boundaries