From 2e525cb138cb9acaf7293649c3e9ae3d44936e47 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 11 Sep 2026 21:58:48 +0300 Subject: [PATCH] P5.1: record a refused child-result disposition as an argument error Owner item I23: a typed refusal was being recorded as status=ok. Add one row to _EXACT_IDENTIFIER_CODES mapping CHILD_RESULT_DISPOSITION_INVALID to the EXISTING TOOL_ARG_ERROR code, matching the typed arms join_ledger.py already publishes for the single form. The register is BY CODE, so this one row also covers the three plain-string producers that never publish a typed result (the batch envelope and per-entry rejections in join_ledger.py, the ledger-append path in task_tree_ledger.py, and the task_tree.py schema text) and re-reads the stored traces of every past refusal, which a producer-only cutover cannot. CHILD_RESULT_DISPOSITION_PARTIAL deliberately stays LEGACY_WARNING: those entries did record. ouroboros/tools/tool_result.py is SAFETY_CRITICAL (runtime_mode_policy.py:229). The diff in that file is exactly the one table row named by the phase; no spec, no bucket, no producer and no classifier branch changed. HELD, not implemented here: the STEER_REJECTED and STEER_UNCONFIRMED rows of the same owner item wait for an owner answer on whether a refused steer touches the task's health axis (batch #3, R3-32). Tests: one APPROVED_DELTAS row in tests/test_tool_classification_differential.py (the table fails both on unapproved divergence and on rows that no longer fire), and the four text-only pins in tests/test_child_result_disposition.py now also assert the typed code, including the partial-batch carve-out. The frozen golden fixture is untouched: ident:CHILD_RESULT_DISPOSITION_INVALID already exists in it. --- ouroboros/tools/tool_result.py | 1 + tests/test_child_result_disposition.py | 16 ++++++++++++++++ tests/test_tool_classification_differential.py | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/ouroboros/tools/tool_result.py b/ouroboros/tools/tool_result.py index 4fcda8f3b..54d1673be 100644 --- a/ouroboros/tools/tool_result.py +++ b/ouroboros/tools/tool_result.py @@ -717,6 +717,7 @@ _EXACT_IDENTIFIER_CODES = MappingProxyType( "BROWSER_BACKLOG_RETIRED_SESSIONS": "LEGACY_UNAVAILABLE", "TOOL_ARG_ERROR": "TOOL_ARG_ERROR", "INVALID_ARG": "TOOL_ARG_ERROR", + "CHILD_RESULT_DISPOSITION_INVALID": "TOOL_ARG_ERROR", "TOOL_ERROR": "TOOL_ERROR", "TOOL_INTERNAL_ERROR": "TOOL_INTERNAL_ERROR", "EXECUTOR_UNAVAILABLE": "LEGACY_UNAVAILABLE", diff --git a/tests/test_child_result_disposition.py b/tests/test_child_result_disposition.py index 3fbfddb13..a251fb399 100644 --- a/tests/test_child_result_disposition.py +++ b/tests/test_child_result_disposition.py @@ -16,6 +16,17 @@ def _parent_ctx(tmp_path, task_id: str = "parent1") -> SimpleNamespace: ) +def _typed_code(text: str) -> str: + """The code the one classifier assigns to a refusal sentence (owner item I23). + + The plain-string producers below never publish a typed result, so the + identifier table is what decides whether a refused disposition is recorded as + an argument error or as a success.""" + from ouroboros.tools.tool_result import LegacyTextResultAdapter + + return LegacyTextResultAdapter.from_text("tree_note", text).code + + def _payload(child_id: str, disposition: str, result_sha256: str) -> dict: return { "type": "child_result_disposition", @@ -236,6 +247,7 @@ def test_malformed_disposition_names_every_violation_in_one_reply(tmp_path): result = _tree_note(_parent_ctx(tmp_path), "decision", "x" * 501, payload=bad) assert result.count("CHILD_RESULT_DISPOSITION_INVALID") == 1 + assert _typed_code(result) == "TOOL_ARG_ERROR" for fragment in ( "unknown key(s) supports_claims", "disposition must be one of", @@ -281,6 +293,7 @@ def test_ledger_append_renders_the_same_aggregated_violations(tmp_path): ) assert out.startswith("⚠️ CHILD_RESULT_DISPOSITION_INVALID:") + assert _typed_code(out) == "TOOL_ARG_ERROR" for fragment in ( "unknown key(s) supports_claims", "disposition must be one of", @@ -394,6 +407,8 @@ def test_batch_disposition_rejects_invalid_entries_individually(tmp_path): assert "[stranger9] ⚠️ CHILD_RESULT_LINEAGE_FORBIDDEN" in result assert "disposition must be one of" in result assert "[entry 4] ⚠️ CHILD_RESULT_DISPOSITION_INVALID: entry must be a JSON object." in result + # A partial batch stays a warning: the recorded entries are real work. + assert _typed_code(result) == "LEGACY_WARNING" rows = tree_ledger_rows("parent1", data_root=tmp_path) assert [row["payload"]["child_task_id"] for row in rows] == ["child1"] @@ -413,6 +428,7 @@ def test_batch_disposition_envelope_is_validated_atomically(tmp_path): result = _tree_note(_parent_ctx(tmp_path), "decision", "why", payload=payload) assert "CHILD_RESULT_DISPOSITION_INVALID" in result assert "atomic no-op" in result + assert _typed_code(result) == "TOOL_ARG_ERROR" wrong_kind = _tree_note( _parent_ctx(tmp_path), "note", diff --git a/tests/test_tool_classification_differential.py b/tests/test_tool_classification_differential.py index 4010b99dd..571edd92e 100644 --- a/tests/test_tool_classification_differential.py +++ b/tests/test_tool_classification_differential.py @@ -259,6 +259,14 @@ APPROVED_DELTAS: Mapping[str, Delta] = MappingProxyType({ "native:LEGACY_UNAVAILABLE:CHILD_RESULT_STALE": Delta(False, "ok", True, "unavailable", "A.B7", "join_ledger has no current child result to bind, unlike a changed result's policy denial"), "native:LEGACY_UNAVAILABLE:TASK_NOT_FOUND": Delta(False, "ok", True, "unavailable", "A.B7", "forward_to_worker has no registered target for this task id"), "native:TOOL_ARG_ERROR:CHILD_RESULT_DISPOSITION_INVALID": Delta(False, "ok", True, "argument_error", "A.B7", "join_ledger rejects malformed disposition arguments before recording them"), + # Owner item I23: a typed refusal must be recorded as a refusal, not as ok. + # The single form already publishes the typed argument error above; the batch + # envelope, the per-entry rejections and the ledger-append path return the + # same sentence as a PLAIN STRING, so only the identifier table reaches them + # (and the stored traces of every past refusal). The register is BY CODE, so + # one row covers all four producers. CHILD_RESULT_DISPOSITION_PARTIAL keeps + # its warning: those entries did record. + "CHILD_RESULT_DISPOSITION_INVALID": Delta(False, "ok", True, "argument_error", "A.I23", "a disposition the ledger refused to record is an argument error, not a success"), "native:TOOL_ARG_ERROR:ERROR": Delta(False, "ok", True, "argument_error", "A.B7", "both commit entry points reject an empty commit message before attempting a commit"), "native:TOOL_ARG_ERROR:REJECTED": Delta(False, "ok", True, "argument_error", "A.B7", "scratchpad and identity writers reject empty or malformed content before writing"), "native:TOOL_ERROR:TASK_MESSAGE_UNWRITTEN": Delta(False, "ok", True, "error", "A.B7", "forward_to_worker failed to persist the requested message"),