From 59f69e303678151cb3e37c11283f15eb1e65bf20 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 4 Sep 2026 19:46:35 +0800 Subject: [PATCH 1/7] feat(session-runtime): type raw-material key classification Replace the substring denylist in build_session_runtime_readonly_projection with a word-level, typed rule: keys split on _, -, and camelCase and match exact keys or whole words only. Each key lands in one of three states: compact (allowed), raw material (flagged with a RawMaterialCategory), or unclassified (reported in unclassified_key_names, never blocking). The substring rule flagged usage metrics and pointers such as token_count, max_tokens, trace_id, catalog_id, and login_at, which switched agent_can_continue and must_attempt_work off for compact input, while it missed messages, prompt, api_key, password, body, file_path, diff, and stdout_tail. This is contract hardening ahead of the first producer; the builder has no production callers yet. Additive boundary fields raw_material_categories and unclassified_key_names survive run compaction as bounded lists. schema_version is unchanged. Signed-off-by: song --- .../control_plane/runtime/session_runtime.py | 11 +- loopx/session_runtime.py | 208 ++++++++++++++++-- 2 files changed, 193 insertions(+), 26 deletions(-) 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..0173ce3727 100644 --- a/loopx/session_runtime.py +++ b/loopx/session_runtime.py @@ -1,24 +1,47 @@ -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 or whole +words, 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 +55,105 @@ "ref_id", ) +TIMESTAMP_KEYS = ("created_at", "event_at", "updated_at", "timestamp") + +# Exact keys the projection itself reads, plus its input booleans. +COMPACT_KEYS = frozenset( + { + *SOURCE_ID_KEYS, + *TIMESTAMP_KEYS, + "kind", + "type", + "status", + "state", + "actor", + "required_actor", + "decision_actor", + "channel", + "requires_human_decision", + "action_required", + "advisory", + "blocking", + "question", + "requested_decision", + "title", + "summary", + "message", + "next_action", + "recommended_action", + "agent_next_action", + "handoff", + "validation_summary", + "validated", + "result", + "blocker", + "blocker_summary", + } +) + +# A key whose last word is a pointer or a count is never the material itself: +# ``trace_id``, ``catalog_id``, ``token_count``, ``login_at``. +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, + "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"}), + ), +) + +_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 +285,53 @@ 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 classify_session_runtime_key(key: str) -> KeyClassification: + """Classify one input key as compact, raw material, or unclassified. + + Matching is exact-key or whole-word only; substrings never match. + """ + + 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) + if words[-1] in COMPACT_SUFFIX_WORDS or COMPACT_METRIC_WORDS.intersection(words): + return KeyClassification(KeyState.COMPACT) + for category, raw_words in RAW_MATERIAL_WORDS: + if raw_words.intersection(words): + return KeyClassification(KeyState.RAW_MATERIAL, category) + 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: @@ -296,8 +456,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 +487,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 +541,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, From 190a0ae52b96dcd7b9e6fd30a683b93e02c1eb9b Mon Sep 17 00:00:00 2001 From: song Date: Fri, 4 Sep 2026 19:46:35 +0800 Subject: [PATCH 2/7] test(session-runtime): cover word-level raw-material classification Smoke: assert the compact look-alike keys (token counts, *_id, *_at) keep agent_can_continue on, unclassified keys are reported without blocking, and each formerly missed raw-material key is flagged with its value never copied. Pytest: parametrized classifier contract per state and category, substring non-matching, unclassified bound, and compaction passthrough of the two new bounded boundary lists. Signed-off-by: song --- ...ssion-runtime-readonly-projection-smoke.py | 84 ++++++++++++ ...test_session_runtime_key_classification.py | 125 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 tests/test_session_runtime_key_classification.py diff --git a/examples/session_runtime/session-runtime-readonly-projection-smoke.py b/examples/session_runtime/session-runtime-readonly-projection-smoke.py index e2743505f0..ee5e8e6a1f 100644 --- a/examples/session_runtime/session-runtime-readonly-projection-smoke.py +++ b/examples/session_runtime/session-runtime-readonly-projection-smoke.py @@ -165,10 +165,92 @@ 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", +) + + +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 +350,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/tests/test_session_runtime_key_classification.py b/tests/test_session_runtime_key_classification.py new file mode 100644 index 0000000000..117359e9b2 --- /dev/null +++ b/tests/test_session_runtime_key_classification.py @@ -0,0 +1,125 @@ +"""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", "message", "recommended_action", + "created_at", "session_id", "artifact_id", + # pointers and counts that merely contain a raw-looking word + "trace_id", "catalog_id", "dialog_id", "login_at", "log_count", + # usage metrics + "token_count", "tokens_used", "max_tokens", "input_tokens", "output_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", ""]) +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), + ("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 + + +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 From cf403721cba684e43a5d066cec7a8f0e25640fd6 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 4 Sep 2026 19:46:35 +0800 Subject: [PATCH 3/7] docs(session-runtime): document typed boundary key states State the word-level classification rule and its three states (compact, raw material, unclassified) in the adapter guide and the projection v0 protocol, including the token, trace, and log word rules. Signed-off-by: song --- .../session-runtime-control-plane-adapter.md | 19 +++++++++++++++++++ .../session-runtime-loopx-projection-v0.md | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 15e53498e1..aa5c52ad24 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -154,6 +154,25 @@ 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 or whole words, +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, pointers ending in `_id`/`_count`/`_at` (`trace_id`, `catalog_id`), usage metrics (`token_count`, `max_tokens`) | +| raw material | `raw_material_detected`, `agent_can_continue=false`, category recorded in `raw_material_categories` | `credential` (`api_key`, `access_token`, `password`), `transcript` (`raw_transcript`, `messages`, `prompt`, `body`), `log` (`log_path`, `stack_trace`), `local_path` (`file_path`), `raw_output` (`stdout_tail`, `diff`) | +| 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. `trace_id` is a pointer; `trace`, +`stack_trace`, and `trace_path` are logs. `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..7629ff209f 100644 --- a/docs/reference/protocols/session-runtime-loopx-projection-v0.md +++ b/docs/reference/protocols/session-runtime-loopx-projection-v0.md @@ -64,6 +64,20 @@ 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 or whole words after splitting on `_`, `-`, and +camelCase; never substrings). Values are never inspected or copied. + +- **compact**: keys the projection reads, timestamps, pointers (`*_id`, + `*_count`, `*_at`), and usage metrics (`*_tokens`). Allowed. +- **raw material**: credentials, 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. + 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. From 968976c571cae687ec9abbaa2340bddce7bae226 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 5 Sep 2026 00:12:21 +0800 Subject: [PATCH 4/7] fix(session-runtime): keep raw material out of compact projection Signed-off-by: song --- .../session-runtime-control-plane-adapter.md | 5 ++- .../session-runtime-loopx-projection-v0.md | 7 ++-- loopx/session_runtime.py | 11 +++--- ...test_session_runtime_key_classification.py | 38 ++++++++++++++++++- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index aa5c52ad24..899425df27 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -164,12 +164,13 @@ 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, pointers ending in `_id`/`_count`/`_at` (`trace_id`, `catalog_id`), usage metrics (`token_count`, `max_tokens`) | -| raw material | `raw_material_detected`, `agent_can_continue=false`, category recorded in `raw_material_categories` | `credential` (`api_key`, `access_token`, `password`), `transcript` (`raw_transcript`, `messages`, `prompt`, `body`), `log` (`log_path`, `stack_trace`), `local_path` (`file_path`), `raw_output` (`stdout_tail`, `diff`) | +| 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`), `transcript` (`message`, `raw_transcript`, `messages`, `prompt`, `body`), `log` (`log_path`, `stack_trace`), `local_path` (`file_path`), `raw_output` (`stdout_tail`, `diff`) | | 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. `trace_id` is a pointer; `trace`, +forms such as `tokens_used` are compact, but a raw-material word in the same +key takes precedence (`tokens_password` and `raw_tokens` are raw). `trace_id` is a pointer; `trace`, `stack_trace`, and `trace_path` are logs. `log` matches only as a whole word, so `catalog_id`, `login_at`, and `changelog` are not flagged. diff --git a/docs/reference/protocols/session-runtime-loopx-projection-v0.md b/docs/reference/protocols/session-runtime-loopx-projection-v0.md index 7629ff209f..40ababa84e 100644 --- a/docs/reference/protocols/session-runtime-loopx-projection-v0.md +++ b/docs/reference/protocols/session-runtime-loopx-projection-v0.md @@ -68,11 +68,13 @@ can continue: The `boundary` block reports how input keys were classified, using a typed word-level rule (exact keys or whole words after splitting on `_`, `-`, and -camelCase; never substrings). Values are never inspected or copied. +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, pointers (`*_id`, `*_count`, `*_at`), and usage metrics (`*_tokens`). Allowed. -- **raw material**: credentials, transcripts, logs, local paths, and raw tool +- **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) @@ -143,4 +145,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/loopx/session_runtime.py b/loopx/session_runtime.py index 0173ce3727..2321dfc1aa 100644 --- a/loopx/session_runtime.py +++ b/loopx/session_runtime.py @@ -78,7 +78,6 @@ class KeyClassification(NamedTuple): "requested_decision", "title", "summary", - "message", "next_action", "recommended_action", "agent_next_action", @@ -120,6 +119,7 @@ class KeyClassification(NamedTuple): "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, @@ -305,11 +305,13 @@ def classify_session_runtime_key(key: str) -> KeyClassification: category = RAW_MATERIAL_KEYS.get(normalized) if category is not None: return KeyClassification(KeyState.RAW_MATERIAL, category) - if words[-1] in COMPACT_SUFFIX_WORDS or COMPACT_METRIC_WORDS.intersection(words): + if words[-1] in COMPACT_SUFFIX_WORDS: return KeyClassification(KeyState.COMPACT) for category, raw_words in RAW_MATERIAL_WORDS: if raw_words.intersection(words): return KeyClassification(KeyState.RAW_MATERIAL, category) + if COMPACT_METRIC_WORDS.intersection(words): + return KeyClassification(KeyState.COMPACT) return KeyClassification(KeyState.UNCLASSIFIED) @@ -344,7 +346,6 @@ def _first_user_todo(gate: Mapping[str, Any] | None) -> str | None: "requested_decision", "title", "summary", - "message", "next_action", ), ) @@ -388,7 +389,7 @@ def _latest_validation( return None return _first_text( latest, - ("validation_summary", "validated", "result", "summary", "message"), + ("validation_summary", "validated", "result", "summary"), ) @@ -407,7 +408,7 @@ def _latest_blocker( return None return _first_text( latest, - ("blocker", "blocker_summary", "summary", "message", "title"), + ("blocker", "blocker_summary", "summary", "title"), ) diff --git a/tests/test_session_runtime_key_classification.py b/tests/test_session_runtime_key_classification.py index 117359e9b2..b100506dc6 100644 --- a/tests/test_session_runtime_key_classification.py +++ b/tests/test_session_runtime_key_classification.py @@ -19,7 +19,7 @@ "key", [ # projection inputs - "kind", "status", "actor", "summary", "message", "recommended_action", + "kind", "status", "actor", "summary", "recommended_action", "created_at", "session_id", "artifact_id", # pointers and counts that merely contain a raw-looking word "trace_id", "catalog_id", "dialog_id", "login_at", "log_count", @@ -57,6 +57,7 @@ def test_unclassified_keys_are_neither_compact_nor_raw(key: str) -> None: ("prompt", RawMaterialCategory.TRANSCRIPT), ("content", RawMaterialCategory.TRANSCRIPT), ("body", RawMaterialCategory.TRANSCRIPT), + ("message", RawMaterialCategory.TRANSCRIPT), ("log", RawMaterialCategory.LOG), ("logs", RawMaterialCategory.LOG), ("log_path", RawMaterialCategory.LOG), @@ -85,6 +86,41 @@ def test_substrings_never_match() -> None: 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) + + +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", From 1f18261c22901898d0207efb3bb92c72d512781e Mon Sep 17 00:00:00 2001 From: song Date: Sat, 5 Sep 2026 03:43:17 +0800 Subject: [PATCH 5/7] fix(session-runtime): prioritize raw key evidence Signed-off-by: song --- .../session-runtime-control-plane-adapter.md | 20 ++++--- .../session-runtime-loopx-projection-v0.md | 15 +++-- ...ssion-runtime-readonly-projection-smoke.py | 10 ++++ loopx/session_runtime.py | 54 +++++++++++++++--- ...test_session_runtime_key_classification.py | 55 ++++++++++++++++++- 5 files changed, 132 insertions(+), 22 deletions(-) diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 899425df27..eee8793759 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -158,21 +158,25 @@ summaries, then returns: 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 or whole words, -never as substrings. Every key lands in one of three states: +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, pointers ending in `_id`/`_count`/`_at` (`trace_id`, `catalog_id`), 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`), `transcript` (`message`, `raw_transcript`, `messages`, `prompt`, `body`), `log` (`log_path`, `stack_trace`), `local_path` (`file_path`), `raw_output` (`stdout_tail`, `diff`) | +| 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 in the same -key takes precedence (`tokens_password` and `raw_tokens` are raw). `trace_id` is a pointer; `trace`, -`stack_trace`, and `trace_path` are logs. `log` matches only as a whole word, -so `catalog_id`, `login_at`, and `changelog` are not flagged. +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` is an explicitly safe aggregate. `log` +matches only as a whole word, so `catalog_id`, `login_at`, and `changelog` are +not flagged. Run: diff --git a/docs/reference/protocols/session-runtime-loopx-projection-v0.md b/docs/reference/protocols/session-runtime-loopx-projection-v0.md index 40ababa84e..5cb198aadc 100644 --- a/docs/reference/protocols/session-runtime-loopx-projection-v0.md +++ b/docs/reference/protocols/session-runtime-loopx-projection-v0.md @@ -67,19 +67,26 @@ can continue: ### Boundary Key States The `boundary` block reports how input keys were classified, using a typed -word-level rule (exact keys or whole words after splitting on `_`, `-`, and -camelCase; never substrings). Values from raw-material and unclassified keys +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, pointers (`*_id`, - `*_count`, `*_at`), and usage metrics (`*_tokens`). Allowed. +- **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`, and `log_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. diff --git a/examples/session_runtime/session-runtime-readonly-projection-smoke.py b/examples/session_runtime/session-runtime-readonly-projection-smoke.py index ee5e8e6a1f..27ee560a75 100644 --- a/examples/session_runtime/session-runtime-readonly-projection-smoke.py +++ b/examples/session_runtime/session-runtime-readonly-projection-smoke.py @@ -209,6 +209,16 @@ def test_raw_material_is_flagged_not_copied() -> None: "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", ) diff --git a/loopx/session_runtime.py b/loopx/session_runtime.py index 2321dfc1aa..00ecf982bc 100644 --- a/loopx/session_runtime.py +++ b/loopx/session_runtime.py @@ -1,9 +1,10 @@ """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 or whole -words, never arbitrary substrings. The earlier substring denylist flagged -``token_count`` and ``catalog_id`` while missing ``messages`` and ``api_key``. +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 @@ -57,11 +58,22 @@ class KeyClassification(NamedTuple): 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( + { + "log_count", + "message_id", + "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", @@ -90,8 +102,8 @@ class KeyClassification(NamedTuple): } ) -# A key whose last word is a pointer or a count is never the material itself: -# ``trace_id``, ``catalog_id``, ``token_count``, ``login_at``. +# 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"}) @@ -151,6 +163,18 @@ class KeyClassification(NamedTuple): ), ) +# 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]+") @@ -290,10 +314,19 @@ def _key_words(key: str) -> list[str]: 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 or whole-word only; substrings never match. + 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) @@ -305,11 +338,14 @@ def classify_session_runtime_key(key: str) -> KeyClassification: category = RAW_MATERIAL_KEYS.get(normalized) if category is not None: return KeyClassification(KeyState.RAW_MATERIAL, category) - if words[-1] in COMPACT_SUFFIX_WORDS: - return KeyClassification(KeyState.COMPACT) for category, raw_words in RAW_MATERIAL_WORDS: - if raw_words.intersection(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) diff --git a/tests/test_session_runtime_key_classification.py b/tests/test_session_runtime_key_classification.py index b100506dc6..6cfaa9756d 100644 --- a/tests/test_session_runtime_key_classification.py +++ b/tests/test_session_runtime_key_classification.py @@ -22,7 +22,7 @@ "kind", "status", "actor", "summary", "recommended_action", "created_at", "session_id", "artifact_id", # pointers and counts that merely contain a raw-looking word - "trace_id", "catalog_id", "dialog_id", "login_at", "log_count", + "trace_id", "message_id", "catalog_id", "dialog_id", "login_at", "log_count", # usage metrics "token_count", "tokens_used", "max_tokens", "input_tokens", "output_tokens", ], @@ -101,6 +101,59 @@ def test_raw_words_outrank_the_tokens_metric_rule( 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), + ], +) +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( From 1a04696e69233511115af21d50ffea9bc2510459 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 5 Sep 2026 04:37:20 +0800 Subject: [PATCH 6/7] fix(session-runtime): pin usage-metric and conversation pointer collisions After raw evidence started to outrank the generic pointer/count suffix shortcut, three common compact keys fell on the raw side because their leading word is also a transcript word: `prompt_tokens` and `prompt_token_count` (standard provider usage metrics) and `conversation_id` (a session pointer). Each would set `raw_material_detected`, close `agent_can_continue`, and replace the first screen with the remediation action although no raw material is present. Add the three keys to `EXPLICIT_COMPACT_COLLISION_KEYS`, the reviewable allowlist that already carries `trace_id`, `message_id`, and `log_count`. No generic rule changes: `prompt_id`, `prompt_text_tokens`, `conversation_ref`, `conversation_log_count`, and `messages_count` stay raw and are now covered as fail-closed counterfactuals next to the exceptions. Both session-runtime docs list the exceptions verbatim. Validation: - python3 -m pytest tests/test_session_runtime_key_classification.py -q -> 89 passed - readonly projection, adapter-doc, and public-safety read-model smokes -> ok - uvx ruff check on the changed Python -> clean Signed-off-by: song --- .../integrations/session-runtime-control-plane-adapter.md | 4 +++- .../protocols/session-runtime-loopx-projection-v0.md | 3 ++- loopx/session_runtime.py | 3 +++ tests/test_session_runtime_key_classification.py | 8 ++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index eee8793759..fd852264dd 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -174,7 +174,9 @@ 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` is an explicitly safe aggregate. `log` +`trace_path` are logs. `log_count`, `prompt_tokens`, and `prompt_token_count` +are explicitly safe aggregates and `conversation_id` is an explicitly safe +pointer; every other `prompt*`, `message*`, or `conversation*` key stays raw. `log` matches only as a whole word, so `catalog_id`, `login_at`, and `changelog` are not flagged. diff --git a/docs/reference/protocols/session-runtime-loopx-projection-v0.md b/docs/reference/protocols/session-runtime-loopx-projection-v0.md index 5cb198aadc..aade8f0ebe 100644 --- a/docs/reference/protocols/session-runtime-loopx-projection-v0.md +++ b/docs/reference/protocols/session-runtime-loopx-projection-v0.md @@ -76,7 +76,8 @@ 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`, and `log_count` are explicit safe exceptions. + `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. diff --git a/loopx/session_runtime.py b/loopx/session_runtime.py index 00ecf982bc..285ae88be9 100644 --- a/loopx/session_runtime.py +++ b/loopx/session_runtime.py @@ -62,8 +62,11 @@ class KeyClassification(NamedTuple): # 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", } ) diff --git a/tests/test_session_runtime_key_classification.py b/tests/test_session_runtime_key_classification.py index 6cfaa9756d..d061dc0ae5 100644 --- a/tests/test_session_runtime_key_classification.py +++ b/tests/test_session_runtime_key_classification.py @@ -23,8 +23,10 @@ "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", # 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: @@ -112,6 +114,12 @@ def test_raw_words_outrank_the_tokens_metric_rule( ("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( From 299cf5127b9b5a3c7a086e4685ac9b349d35b771 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 5 Sep 2026 23:19:32 +0800 Subject: [PATCH 7/7] docs(session-runtime): narrow the transcript key guarantee to the typed rule Review P2 on the previous head: the adapter document claimed that every other `prompt*`, `message*`, or `conversation*` key stays raw, but the classifier only treats `message` as an exact raw key and `messages`, `prompt`, `prompts`, and `conversation` as raw whole words. State that rule verbatim and name the neighbouring keys: `prompt_id`, `prompt_text`, and `conversation_ref` stay raw; `message_count` and `message_ref` are compact pointers; `message_text` is reported as unclassified. No substring denylist is reintroduced and no classification behavior changes; the three neighbours are added to the existing parametrized tests so the document describes a tested contract. Validation: - python3 -m pytest tests/test_session_runtime_key_classification.py -q -> 92 passed - session-runtime adapter-doc and readonly projection smokes -> ok Signed-off-by: song --- docs/integrations/session-runtime-control-plane-adapter.md | 6 +++++- tests/test_session_runtime_key_classification.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index fd852264dd..1f7a051aaf 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -176,7 +176,11 @@ the same key takes precedence over both metric and pointer shortcuts `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; every other `prompt*`, `message*`, or `conversation*` key stays raw. `log` +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. diff --git a/tests/test_session_runtime_key_classification.py b/tests/test_session_runtime_key_classification.py index d061dc0ae5..9cca200973 100644 --- a/tests/test_session_runtime_key_classification.py +++ b/tests/test_session_runtime_key_classification.py @@ -24,6 +24,8 @@ # 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", @@ -33,7 +35,9 @@ 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", ""]) +@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)