diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 15e53498e1..1f7a051aaf 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -154,6 +154,36 @@ summaries, then returns: - `reconcile_rule`: the rule that host logs remain raw facts while LoopX stores only compact control projection. +### Raw-Material Key Classification + +The builder never reads input values to decide whether they are raw material; +it classifies input key names with a typed, word-level rule. Keys are split +into words on `_`, `-`, and camelCase and matched as exact keys, whole words, +or exact word sequences, never as substrings. Every key lands in one of three +states: + +| State | Effect | Examples | +| --- | --- | --- | +| compact | allowed | keys the projection reads (`status`, `summary`, `next_action`), timestamps, pointer/count suffixes only when no raw evidence is present (`catalog_id`, `login_at`), explicit safe collisions (`trace_id`, `message_id`, `log_count`), usage metrics (`token_count`, `max_tokens`) | +| raw material | `raw_material_detected`, `agent_can_continue=false`, category recorded in `raw_material_categories`; its value is never copied | `credential` (`api_key`, `access_token`, `password`, `secret_id`, `api_key_id`), `transcript` (`message`, `raw_transcript`, `messages`, `prompt`, `body`, `transcript_id`), `log` (`log_path`, `stack_trace`), `local_path` (`file_path`), `raw_output` (`stdout_tail`, `diff`, `raw_id`) | +| unclassified | reported in `unclassified_key_names` (bounded), never blocks | `backlog`, `changelog`, `logical_clock` | + +The word `token` is a credential only in auth forms (`token`, `access_token`, +`auth_token`, `api_token`, `bearer_token`, `refresh_token`, `id_token`); count +forms such as `tokens_used` are compact, but a raw-material word or phrase in +the same key takes precedence over both metric and pointer shortcuts +(`tokens_password`, `raw_tokens`, `secret_id`, and `api_key_id` are raw). +`trace_id` is an explicitly safe pointer; `trace`, `stack_trace`, and +`trace_path` are logs. `log_count`, `prompt_tokens`, and `prompt_token_count` +are explicitly safe aggregates and `conversation_id` is an explicitly safe +pointer. Transcript evidence otherwise matches the exact key `message` and the +whole words `messages`, `prompt`, `prompts`, and `conversation`: `prompt_id`, +`prompt_text`, and `conversation_ref` stay raw, `message_count` and +`message_ref` are compact pointers, and `message_text` is reported as +unclassified rather than guessed either way. `log` +matches only as a whole word, so `catalog_id`, `login_at`, and `changelog` are +not flagged. + Run: ```bash diff --git a/docs/reference/protocols/session-runtime-loopx-projection-v0.md b/docs/reference/protocols/session-runtime-loopx-projection-v0.md index b01367aa35..aade8f0ebe 100644 --- a/docs/reference/protocols/session-runtime-loopx-projection-v0.md +++ b/docs/reference/protocols/session-runtime-loopx-projection-v0.md @@ -64,6 +64,30 @@ can continue: | `quota_state` | yes | `eligible`, `throttled`, `monitor_quiet_skip`, `operator_gate`, or `blocked`. | | `boundary` | yes | Read/write scope, private-data rule, and stop condition. | +### Boundary Key States + +The `boundary` block reports how input keys were classified, using a typed +word-level rule (exact keys, whole words, or exact word sequences after +splitting on `_`, `-`, and camelCase; never substrings). Values from +raw-material and unclassified keys +are never copied; values from the explicit compact field contract may be used +to build the bounded projection. + +- **compact**: keys the projection reads, timestamps, usage metrics + (`*_tokens`), and pointers/counts (`*_id`, `*_ref`, `*_count`, `*_at`) only + when no raw-material word or phrase is present. Known collisions such as + `trace_id`, `message_id`, `conversation_id`, `log_count`, `prompt_tokens`, + and `prompt_token_count` are explicit safe exceptions. +- **raw material**: credentials, messages/transcripts, logs, local paths, and raw tool + output. Sets `raw_material_detected`, lists `raw_material_key_names` and + `raw_material_categories`, and turns `agent_can_continue` off. +- **unclassified**: any other key. Listed in `unclassified_key_names` (bounded) + so producers can see contract drift; it never blocks continuation. + +Raw-material evidence takes precedence over a generic pointer suffix. For +example, `secret_id`, `transcript_id`, `raw_id`, and `api_key_id` are raw +material, not compact pointers. + The projection should be useful even when no session is currently attached. In that case, `runtime_id` may be `none`, `session_id` may be `null`, and `latest_validation` should explain which runtime fact is missing. @@ -129,4 +153,3 @@ A session-runtime projection is acceptable when: 4. The projection is read-only unless a separate writeback contract is enabled. 5. Public fixtures contain no raw transcripts, credentials, private links, local paths, or internal project names. - diff --git a/examples/session_runtime/session-runtime-readonly-projection-smoke.py b/examples/session_runtime/session-runtime-readonly-projection-smoke.py index e2743505f0..27ee560a75 100644 --- a/examples/session_runtime/session-runtime-readonly-projection-smoke.py +++ b/examples/session_runtime/session-runtime-readonly-projection-smoke.py @@ -165,10 +165,102 @@ def test_raw_material_is_flagged_not_copied() -> None: "provide compact summaries without raw material before projection" ), payload assert "raw_transcript" in payload["boundary"]["raw_material_key_names"], payload + assert "credential_hint" in payload["boundary"]["raw_material_key_names"], payload assert "local_path" in payload["boundary"]["raw_material_key_names"], payload + assert payload["boundary"]["raw_material_categories"] == [ + "credential", + "local_path", + "transcript", + ], payload assert_no_raw_values(payload) +# Usage metrics and pointers whose words merely contain "token", "log", or +# "trace" are compact input, never raw material. +COMPACT_LOOKALIKE_KEYS = ( + "token_count", + "tokens_used", + "max_tokens", + "input_tokens", + "output_tokens", + "trace_id", + "login_at", + "catalog_id", + "dialog_id", +) +# Keys with no matching word at all are reported, not flagged. +UNCLASSIFIED_KEYS = ("logical_clock", "backlog", "changelog", "drawer") +# Raw material the substring rule used to miss: body text, credentials, local +# paths, and raw tool output in whole-word form. +RAW_MATERIAL_KEYS = ( + "messages", + "content", + "prompt", + "api_key", + "password", + "body", + "output_text", + "file_path", + "diff", + "patch", + "stdout_tail", + "stderr_tail", + "log_path", + "transcript_path", + "access_token", + "auth_token", + # Raw evidence outranks pointer-like suffixes unless the full key is an + # explicit public-safe collision such as ``trace_id``. + "secret_id", + "password_id", + "transcript_id", + "raw_id", + "stdout_id", + "api_key_id", + "access_token_ref", + "tool_result_ref", +) + + +def test_word_level_classification_does_not_block_compact_keys() -> None: + session = { + "session_id": "session-4", + "created_at": "2026-01-01T00:04:00Z", + "next_action": "continue compact projection", + **{key: 1 for key in COMPACT_LOOKALIKE_KEYS}, + **{key: "opaque" for key in UNCLASSIFIED_KEYS}, + } + payload = build_session_runtime_readonly_projection(goal_id="demo-goal", sessions=[session]) + boundary = payload["boundary"] + assert boundary["raw_material_detected"] is False, boundary + assert boundary["raw_material_key_names"] == [], boundary + assert boundary["unclassified_key_names"] == sorted(UNCLASSIFIED_KEYS), boundary + assert payload["first_screen"]["agent_can_continue"] is True, payload + assert payload["work_lane_contract"]["must_attempt_work"] is True, payload + assert payload["first_screen"]["recommended_action"] == ( + "continue compact projection" + ), payload + + +def test_word_level_classification_flags_every_raw_material_key() -> None: + for key in RAW_MATERIAL_KEYS: + payload = build_session_runtime_readonly_projection( + goal_id="demo-goal", + sessions=[ + { + "session_id": "session-5", + "next_action": "continue compact projection", + key: "raw-value-must-not-copy", + } + ], + ) + boundary = payload["boundary"] + assert boundary["raw_material_key_names"] == [key], (key, boundary) + assert boundary["unclassified_key_names"] == [], (key, boundary) + assert payload["first_screen"]["agent_can_continue"] is False, (key, payload) + assert "raw-value-must-not-copy" not in json.dumps(payload), (key, payload) + + def test_status_ingests_projection_first_screen() -> None: payload = build_session_runtime_readonly_projection( goal_id="demo-goal", @@ -268,6 +360,8 @@ def main() -> int: test_operator_gate_first_screen() test_agent_advancement_first_screen() test_raw_material_is_flagged_not_copied() + test_word_level_classification_does_not_block_compact_keys() + test_word_level_classification_flags_every_raw_material_key() test_status_ingests_projection_first_screen() print("session-runtime-readonly-projection-smoke: ok") return 0 diff --git a/loopx/control_plane/runtime/session_runtime.py b/loopx/control_plane/runtime/session_runtime.py index 07f3c281a6..6fea895bfe 100644 --- a/loopx/control_plane/runtime/session_runtime.py +++ b/loopx/control_plane/runtime/session_runtime.py @@ -125,9 +125,14 @@ def compact_session_runtime_boundary( ): if field in boundary: compact[field] = bool(boundary.get(field)) - raw_keys = public_safe_compact_list(boundary.get("raw_material_key_names"), limit=8) - if raw_keys: - compact["raw_material_key_names"] = raw_keys + for field in ( + "raw_material_key_names", + "raw_material_categories", + "unclassified_key_names", + ): + values = public_safe_compact_list(boundary.get(field), limit=8) + if values: + compact[field] = values return compact diff --git a/loopx/session_runtime.py b/loopx/session_runtime.py index 43d5104829..285ae88be9 100644 --- a/loopx/session_runtime.py +++ b/loopx/session_runtime.py @@ -1,24 +1,48 @@ -from __future__ import annotations +"""Read-only session-runtime projection for the LoopX first screen. + +Raw-material classification is typed and word-level. Input keys are split into +words on ``_``, ``-``, and camelCase, then matched against exact keys, whole +words, or exact word sequences, never arbitrary substrings. The earlier +substring denylist flagged ``token_count`` and ``catalog_id`` while missing +``messages`` and ``api_key``. +Every key lands in one of three states: known compact keys are allowed, known +raw-material keys are flagged with a :class:`RawMaterialCategory`, and +unrecognized keys are reported as ``unclassified_key_names`` so producers can +see contract drift without being blocked by it. +""" -from typing import Any, Mapping, Sequence +from __future__ import annotations +import re +from collections.abc import Mapping, Sequence +from enum import Enum +from typing import Any, NamedTuple SESSION_RUNTIME_READONLY_PROJECTION_SCHEMA_VERSION = ( "session_runtime_readonly_projection_v0" ) -RAW_MATERIAL_KEY_HINTS = ( - "credential", - "local_path", - "log", - "raw", - "secret", - "stderr", - "stdout", - "token", - "trace", - "transcript", -) +UNCLASSIFIED_KEY_LIMIT = 24 + + +class KeyState(str, Enum): + COMPACT = "compact" + RAW_MATERIAL = "raw_material" + UNCLASSIFIED = "unclassified" + + +class RawMaterialCategory(str, Enum): + CREDENTIAL = "credential" + TRANSCRIPT = "transcript" + LOG = "log" + LOCAL_PATH = "local_path" + RAW_OUTPUT = "raw_output" + + +class KeyClassification(NamedTuple): + state: KeyState + category: RawMaterialCategory | None = None + SOURCE_ID_KEYS = ( "session_id", @@ -32,6 +56,131 @@ "ref_id", ) +TIMESTAMP_KEYS = ("created_at", "event_at", "updated_at", "timestamp") + +# Public-safe pointers and aggregate counters whose leading word otherwise +# carries raw-material meaning. Keep these exceptions explicit and reviewable. +EXPLICIT_COMPACT_COLLISION_KEYS = frozenset( + { + "conversation_id", + "log_count", + "message_id", + "prompt_token_count", + "prompt_tokens", + "trace_id", + } +) + +# Exact keys the projection itself reads, plus its input booleans. +COMPACT_KEYS = frozenset( + { + *SOURCE_ID_KEYS, + *TIMESTAMP_KEYS, + *EXPLICIT_COMPACT_COLLISION_KEYS, + "kind", + "type", + "status", + "state", + "actor", + "required_actor", + "decision_actor", + "channel", + "requires_human_decision", + "action_required", + "advisory", + "blocking", + "question", + "requested_decision", + "title", + "summary", + "next_action", + "recommended_action", + "agent_next_action", + "handoff", + "validation_summary", + "validated", + "result", + "blocker", + "blocker_summary", + } +) + +# A generic pointer/count suffix is compact only when no exact or word-level +# raw-material evidence matched first. +COMPACT_SUFFIX_WORDS = frozenset({"id", "ids", "ref", "refs", "count", "at"}) +# Usage metrics: ``tokens_used``, ``max_tokens``, ``input_tokens``. +COMPACT_METRIC_WORDS = frozenset({"tokens"}) + +# Exact keys that are raw material even though their words are individually +# ambiguous (``key``, ``token``, ``content``, ``output``). +RAW_MATERIAL_KEYS: Mapping[str, RawMaterialCategory] = { + "token": RawMaterialCategory.CREDENTIAL, + "access_token": RawMaterialCategory.CREDENTIAL, + "auth_token": RawMaterialCategory.CREDENTIAL, + "api_token": RawMaterialCategory.CREDENTIAL, + "bearer_token": RawMaterialCategory.CREDENTIAL, + "refresh_token": RawMaterialCategory.CREDENTIAL, + "id_token": RawMaterialCategory.CREDENTIAL, + "session_token": RawMaterialCategory.CREDENTIAL, + "api_key": RawMaterialCategory.CREDENTIAL, + "apikey": RawMaterialCategory.CREDENTIAL, + "private_key": RawMaterialCategory.CREDENTIAL, + "secret_key": RawMaterialCategory.CREDENTIAL, + "access_key": RawMaterialCategory.CREDENTIAL, + "authorization": RawMaterialCategory.CREDENTIAL, + "cookie": RawMaterialCategory.CREDENTIAL, + "cookies": RawMaterialCategory.CREDENTIAL, + "content": RawMaterialCategory.TRANSCRIPT, + "body": RawMaterialCategory.TRANSCRIPT, + "request_body": RawMaterialCategory.TRANSCRIPT, + "response_body": RawMaterialCategory.TRANSCRIPT, + "message": RawMaterialCategory.TRANSCRIPT, + "output": RawMaterialCategory.RAW_OUTPUT, + "output_text": RawMaterialCategory.RAW_OUTPUT, + "tool_output": RawMaterialCategory.RAW_OUTPUT, + "tool_result": RawMaterialCategory.RAW_OUTPUT, +} + +# Whole words that mark raw material in any position. Category precedence is +# the tuple order, so ``raw_transcript`` is a transcript and ``raw_log`` a log. +RAW_MATERIAL_WORDS: tuple[tuple[RawMaterialCategory, frozenset[str]], ...] = ( + ( + RawMaterialCategory.CREDENTIAL, + frozenset({"credential", "credentials", "secret", "secrets", "password", "passwd", "passphrase"}), + ), + ( + RawMaterialCategory.TRANSCRIPT, + frozenset({"transcript", "transcripts", "messages", "prompt", "prompts", "conversation"}), + ), + ( + RawMaterialCategory.LOG, + frozenset({"log", "logs", "trace", "traces", "stacktrace", "traceback"}), + ), + ( + RawMaterialCategory.LOCAL_PATH, + frozenset({"path", "paths", "cwd", "workdir", "filename", "filepath"}), + ), + ( + RawMaterialCategory.RAW_OUTPUT, + frozenset({"raw", "stdout", "stderr", "diff", "patch", "dump"}), + ), +) + +# Multi-word exact raw keys also remain raw when embedded in a larger key. This +# catches forms such as ``api_key_id`` without treating the ambiguous word +# ``key`` (or a harmless key such as ``monkey_id``) as raw material. +RAW_MATERIAL_KEY_PHRASES: Mapping[RawMaterialCategory, tuple[tuple[str, ...], ...]] = { + category: tuple( + tuple(key.split("_")) + for key, key_category in RAW_MATERIAL_KEYS.items() + if key_category is category and "_" in key + ) + for category, _raw_words in RAW_MATERIAL_WORDS +} + +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +_WORD_SEPARATOR = re.compile(r"[^a-z0-9]+") + OPEN_GATE_STATUSES = { "blocked", "needs_decision", @@ -163,15 +312,67 @@ def _source_refs( } -def _raw_material_keys(*groups: Sequence[Mapping[str, Any]]) -> list[str]: - keys: set[str] = set() +def _key_words(key: str) -> list[str]: + snake = _CAMEL_BOUNDARY.sub("_", str(key)).lower() + return [word for word in _WORD_SEPARATOR.split(snake) if word] + + +def _contains_word_sequence(words: Sequence[str], phrase: tuple[str, ...]) -> bool: + width = len(phrase) + return any( + tuple(words[index : index + width]) == phrase + for index in range(len(words) - width + 1) + ) + + +def classify_session_runtime_key(key: str) -> KeyClassification: + """Classify one input key as compact, raw material, or unclassified. + + Matching is exact-key, whole-word, or exact word-sequence only; substrings + never match. Raw evidence outranks generic pointer and metric shortcuts. + """ + + words = _key_words(key) + if not words: + return KeyClassification(KeyState.UNCLASSIFIED) + normalized = "_".join(words) + if normalized in COMPACT_KEYS: + return KeyClassification(KeyState.COMPACT) + category = RAW_MATERIAL_KEYS.get(normalized) + if category is not None: + return KeyClassification(KeyState.RAW_MATERIAL, category) + for category, raw_words in RAW_MATERIAL_WORDS: + if raw_words.intersection(words) or any( + _contains_word_sequence(words, phrase) + for phrase in RAW_MATERIAL_KEY_PHRASES[category] + ): + return KeyClassification(KeyState.RAW_MATERIAL, category) + if words[-1] in COMPACT_SUFFIX_WORDS: + return KeyClassification(KeyState.COMPACT) + if COMPACT_METRIC_WORDS.intersection(words): + return KeyClassification(KeyState.COMPACT) + return KeyClassification(KeyState.UNCLASSIFIED) + + +def _classify_keys( + *groups: Sequence[Mapping[str, Any]], +) -> tuple[list[str], list[str], list[str]]: + """Return sorted raw-material key names, their categories, and unclassified names.""" + + raw_keys: set[str] = set() + categories: set[str] = set() + unclassified: set[str] = set() for group in groups: for item in group: for key in item: - lowered = str(key).lower() - if any(hint in lowered for hint in RAW_MATERIAL_KEY_HINTS): - keys.add(str(key)) - return sorted(keys) + classification = classify_session_runtime_key(str(key)) + if classification.state is KeyState.RAW_MATERIAL: + raw_keys.add(str(key)) + if classification.category is not None: + categories.add(classification.category.value) + elif classification.state is KeyState.UNCLASSIFIED: + unclassified.add(str(key)) + return sorted(raw_keys), sorted(categories), sorted(unclassified)[:UNCLASSIFIED_KEY_LIMIT] def _first_user_todo(gate: Mapping[str, Any] | None) -> str | None: @@ -184,7 +385,6 @@ def _first_user_todo(gate: Mapping[str, Any] | None) -> str | None: "requested_decision", "title", "summary", - "message", "next_action", ), ) @@ -228,7 +428,7 @@ def _latest_validation( return None return _first_text( latest, - ("validation_summary", "validated", "result", "summary", "message"), + ("validation_summary", "validated", "result", "summary"), ) @@ -247,7 +447,7 @@ def _latest_blocker( return None return _first_text( latest, - ("blocker", "blocker_summary", "summary", "message", "title"), + ("blocker", "blocker_summary", "summary", "title"), ) @@ -296,8 +496,8 @@ def build_session_runtime_readonly_projection( This adapter is intentionally read-only. It consumes only compact summaries and source pointers, never raw transcripts, logs, credentials, or local - paths. If raw-looking keys are present, the projection records a boundary - violation without copying their values. + paths. Known raw-material keys are recorded as a boundary violation without + copying their values; unrecognized keys are reported but do not block. """ session_items = _as_mappings(sessions) @@ -327,7 +527,7 @@ def build_session_runtime_readonly_projection( blocker=blocker, first_agent_todo=agent_todo, ) - raw_keys = _raw_material_keys( + raw_keys, raw_categories, unclassified_keys = _classify_keys( session_items, event_items, outcome_items, @@ -381,6 +581,8 @@ def build_session_runtime_readonly_projection( "runtime_mutation_allowed": False, "raw_material_detected": bool(raw_keys), "raw_material_key_names": raw_keys, + "raw_material_categories": raw_categories, + "unclassified_key_names": unclassified_keys, }, "first_screen": { "waiting_on": waiting, diff --git a/tests/test_session_runtime_key_classification.py b/tests/test_session_runtime_key_classification.py new file mode 100644 index 0000000000..9cca200973 --- /dev/null +++ b/tests/test_session_runtime_key_classification.py @@ -0,0 +1,226 @@ +"""Typed word-level raw-material classification for the session-runtime projection.""" + +from __future__ import annotations + +import pytest + +from loopx.control_plane.runtime.session_runtime import ( + compact_session_runtime_readonly_projection, +) +from loopx.session_runtime import ( + KeyState, + RawMaterialCategory, + build_session_runtime_readonly_projection, + classify_session_runtime_key, +) + + +@pytest.mark.parametrize( + "key", + [ + # projection inputs + "kind", "status", "actor", "summary", "recommended_action", + "created_at", "session_id", "artifact_id", + # pointers and counts that merely contain a raw-looking word + "trace_id", "message_id", "catalog_id", "dialog_id", "login_at", "log_count", + "conversation_id", + # `message` is an exact raw key, not a raw word: its pointer/count neighbours stay compact + "message_count", "message_ref", + # usage metrics + "token_count", "tokens_used", "max_tokens", "input_tokens", "output_tokens", + "prompt_tokens", "prompt_token_count", "completion_tokens", + ], +) +def test_compact_keys(key: str) -> None: + assert classify_session_runtime_key(key).state is KeyState.COMPACT + + +@pytest.mark.parametrize( + "key", ["logical_clock", "backlog", "changelog", "drawer", "content_type", "message_text", ""] +) +def test_unclassified_keys_are_neither_compact_nor_raw(key: str) -> None: + assert classify_session_runtime_key(key) == (KeyState.UNCLASSIFIED, None) + + +@pytest.mark.parametrize( + ("key", "category"), + [ + ("token", RawMaterialCategory.CREDENTIAL), + ("access_token", RawMaterialCategory.CREDENTIAL), + ("auth_token", RawMaterialCategory.CREDENTIAL), + ("api_token", RawMaterialCategory.CREDENTIAL), + ("bearer_token", RawMaterialCategory.CREDENTIAL), + ("refresh_token", RawMaterialCategory.CREDENTIAL), + ("id_token", RawMaterialCategory.CREDENTIAL), + ("accessToken", RawMaterialCategory.CREDENTIAL), + ("api_key", RawMaterialCategory.CREDENTIAL), + ("password", RawMaterialCategory.CREDENTIAL), + ("secret", RawMaterialCategory.CREDENTIAL), + ("credential_hint", RawMaterialCategory.CREDENTIAL), + ("raw_transcript", RawMaterialCategory.TRANSCRIPT), + ("transcript_path", RawMaterialCategory.TRANSCRIPT), + ("messages", RawMaterialCategory.TRANSCRIPT), + ("prompt", RawMaterialCategory.TRANSCRIPT), + ("content", RawMaterialCategory.TRANSCRIPT), + ("body", RawMaterialCategory.TRANSCRIPT), + ("message", RawMaterialCategory.TRANSCRIPT), + ("log", RawMaterialCategory.LOG), + ("logs", RawMaterialCategory.LOG), + ("log_path", RawMaterialCategory.LOG), + ("raw_log", RawMaterialCategory.LOG), + ("trace", RawMaterialCategory.LOG), + ("traces", RawMaterialCategory.LOG), + ("stack_trace", RawMaterialCategory.LOG), + ("trace_path", RawMaterialCategory.LOG), + ("raw_trace", RawMaterialCategory.LOG), + ("local_path", RawMaterialCategory.LOCAL_PATH), + ("file_path", RawMaterialCategory.LOCAL_PATH), + ("output_text", RawMaterialCategory.RAW_OUTPUT), + ("stdout_tail", RawMaterialCategory.RAW_OUTPUT), + ("stderr_tail", RawMaterialCategory.RAW_OUTPUT), + ("diff", RawMaterialCategory.RAW_OUTPUT), + ("patch", RawMaterialCategory.RAW_OUTPUT), + ], +) +def test_raw_material_keys_carry_a_typed_category(key: str, category: RawMaterialCategory) -> None: + assert classify_session_runtime_key(key) == (KeyState.RAW_MATERIAL, category) + + +def test_substrings_never_match() -> None: + # Every hint of the retired substring denylist embedded in a longer word. + for key in ("catalog", "dialogue", "backlog", "tokenizer", "tracer", "drawn", "rawhide"): + assert classify_session_runtime_key(key).state is not KeyState.RAW_MATERIAL, key + + +@pytest.mark.parametrize( + ("key", "category"), + [ + ("tokens_password", RawMaterialCategory.CREDENTIAL), + ("tokens_transcript", RawMaterialCategory.TRANSCRIPT), + ("raw_tokens", RawMaterialCategory.RAW_OUTPUT), + ], +) +def test_raw_words_outrank_the_tokens_metric_rule( + key: str, + category: RawMaterialCategory, +) -> None: + assert classify_session_runtime_key(key) == (KeyState.RAW_MATERIAL, category) + + +@pytest.mark.parametrize( + ("key", "category"), + [ + ("secret_id", RawMaterialCategory.CREDENTIAL), + ("password_id", RawMaterialCategory.CREDENTIAL), + ("transcript_id", RawMaterialCategory.TRANSCRIPT), + ("raw_id", RawMaterialCategory.RAW_OUTPUT), + ("stdout_id", RawMaterialCategory.RAW_OUTPUT), + ("api_key_id", RawMaterialCategory.CREDENTIAL), + ("access_token_ref", RawMaterialCategory.CREDENTIAL), + ("tool_result_ref", RawMaterialCategory.RAW_OUTPUT), + # neighbours of the explicit safe collisions stay fail-closed + ("prompt_id", RawMaterialCategory.TRANSCRIPT), + ("prompt_text_tokens", RawMaterialCategory.TRANSCRIPT), + ("conversation_ref", RawMaterialCategory.TRANSCRIPT), + ("conversation_log_count", RawMaterialCategory.TRANSCRIPT), + ("messages_count", RawMaterialCategory.TRANSCRIPT), + ], +) +def test_raw_evidence_outranks_generic_pointer_suffix( + key: str, + category: RawMaterialCategory, +) -> None: + assert classify_session_runtime_key(key) == (KeyState.RAW_MATERIAL, category) + + +@pytest.mark.parametrize( + ("key", "category"), + [ + ("secret_id", RawMaterialCategory.CREDENTIAL), + ("api_key_id", RawMaterialCategory.CREDENTIAL), + ("transcript_id", RawMaterialCategory.TRANSCRIPT), + ("raw_id", RawMaterialCategory.RAW_OUTPUT), + ], +) +def test_pointer_shaped_raw_key_blocks_projection_without_copying_value( + key: str, + category: RawMaterialCategory, +) -> None: + marker = f"RAW_POINTER_MARKER_{key}" + payload = build_session_runtime_readonly_projection( + goal_id="g", + sessions=[ + { + "session_id": "s", + "next_action": "advance", + key: marker, + } + ], + ) + + assert payload["boundary"]["raw_material_detected"] is True + assert payload["boundary"]["raw_material_key_names"] == [key] + assert payload["boundary"]["raw_material_categories"] == [category.value] + assert payload["first_screen"]["agent_can_continue"] is False + assert payload["work_lane_contract"]["must_attempt_work"] is False + assert marker not in repr(payload) + + +def test_message_material_is_flagged_and_never_copied_to_first_screen() -> None: + marker = "RAW_TRANSCRIPT_MARKER full conversation material" + payload = build_session_runtime_readonly_projection( + goal_id="g", + events=[ + { + "event_id": "e1", + "kind": "blocker", + "status": "blocked", + "message": marker, + } + ], + ) + assert payload["boundary"]["raw_material_detected"] is True + assert payload["boundary"]["raw_material_key_names"] == ["message"] + assert payload["boundary"]["raw_material_categories"] == ["transcript"] + assert marker not in repr(payload) + assert payload["first_screen"]["latest_blocker"] is None + + +def test_unclassified_keys_are_reported_but_do_not_block() -> None: + payload = build_session_runtime_readonly_projection( + goal_id="g", + sessions=[{"session_id": "s", "next_action": "advance", "backlog": "x", "drawer": "y"}], + ) + assert payload["boundary"]["raw_material_detected"] is False + assert payload["boundary"]["unclassified_key_names"] == ["backlog", "drawer"] + assert payload["first_screen"]["agent_can_continue"] is True + assert payload["work_lane_contract"]["must_attempt_work"] is True + + +def test_unclassified_key_report_is_bounded() -> None: + payload = build_session_runtime_readonly_projection( + goal_id="g", + sessions=[{f"opaque{index:03d}": index for index in range(40)}], + ) + assert len(payload["boundary"]["unclassified_key_names"]) == 24 + + +def test_compaction_keeps_typed_boundary_fields_bounded() -> None: + payload = build_session_runtime_readonly_projection( + goal_id="g", + sessions=[ + { + "session_id": "s", + "api_key": "k", + "raw_transcript": "t", + "backlog": "b", + **{f"opaque{index:02d}": index for index in range(12)}, + } + ], + ) + compact = compact_session_runtime_readonly_projection(payload) + assert compact is not None + boundary = compact["boundary"] + assert boundary["raw_material_key_names"] == ["api_key", "raw_transcript"] + assert boundary["raw_material_categories"] == ["credential", "transcript"] + assert len(boundary["unclassified_key_names"]) == 8