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.
This commit is contained in:
Anton 2026-09-11 21:58:48 +03:00 • committed by Ouroboros
parent 8c9ca1ea1c
commit 2e525cb138
3 changed files with 25 additions and 0 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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"),