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:
| Entity | Restricted iff | Unresolvable |
|---|---|---|
| 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
| Seam | Scenario | Where |
|---|---|---|
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
- No new migrations (schema-seed merge-order policy), no OpenAPI
churn (no route/DTO surface change;
make openapiclean), no new workflows, no new cloud capability. - No wrapper-token minter — that is T3b-1, which this gate now
blocks until it calls
gate_fetch_link_mint. No channel-ingress consumer wiring — the launch core consumes the gate the moment a consumer passes a non-app channel. - The
prepare_launchsignature gains a requiredchannelkeyword (internal call surface, one production caller) so no caller can launch without declaring its channel.