Skip to content

Commit fc97982

Browse files
Trecekclaude
andauthored
Rectify: Recipe Validation Envelope Hides Semantic Errors Behind "unknown structural error" (#4065)
## Summary `_recipe_validation_error_response` in `server/tools/tools_kitchen.py:100-115` constructs its `user_visible_message` and `error` fields exclusively from `result["errors"]` (the structural error list). When `valid=False` is driven by ERROR-severity semantic or contract findings — which live in `result["suggestions"]`, never in `result["errors"]` — the envelope falls back to the literal string `"unknown structural error"`, hiding every real error message from the user. The architectural weakness is a **split-channel error model with no consumer-side merge**: `compute_recipe_validity` (registry.py:245-254) derives `valid` from three independent sources (`errors`, semantic findings, contract findings), but the envelope builder reads only one source. The fleet dispatch path (`fleet/_api.py:374-388`) already handles this correctly by merging both channels — the open_kitchen envelope is the sole consumer that doesn't. ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/remediation-20260611-063512-882312/.autoskillit/temp/rectify/rectify_envelope_hides_semantic_errors_2026-06-11_064500.md` Closes #4060 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | run_bem* | sonnet | 1 | 45.1k | 48.1k | 677.2k | 130.5k | 35 | 112.5k | 17m 18s | | dispatch:bf3ba4d0-338f-46e2-b744-1cc5af0cb74f* | sonnet | 1 | 66 | 6.1k | 324.1k | 48.4k | 17 | 29.4k | 18m 48s | | plan* | opus[1m] | 1 | 78 | 23.7k | 1.8M | 103.4k | 46 | 104.1k | 13m 39s | | verify* | sonnet | 1 | 799 | 10.1k | 596.8k | 55.8k | 33 | 34.9k | 5m 15s | | implement* | MiniMax-M3 | 1 | 1.4M | 12.1k | 532.7k | 64.8k | 86 | 0 | 9m 43s | | audit_impl* | sonnet | 1 | 190 | 9.1k | 248.7k | 45.8k | 18 | 34.9k | 4m 50s | | prepare_pr* | MiniMax-M3 | 1 | 110.2k | 2.7k | 85.0k | 44.1k | 13 | 0 | 1m 39s | | compose_pr* | MiniMax-M3 | 1 | 177.3k | 1.7k | 0 | 0 | 11 | 0 | 1m 5s | | review_pr* | sonnet | 1 | 156 | 24.6k | 948.4k | 75.1k | 44 | 98.7k | 7m 31s | | resolve_review* | sonnet | 1 | 133 | 9.9k | 833.6k | 66.4k | 37 | 46.2k | 5m 37s | | **Total** | | | 1.8M | 147.9k | 6.0M | 130.5k | | 460.5k | 1h 25m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | run_bem | 0 | — | — | — | | dispatch:bf3ba4d0-338f-46e2-b744-1cc5af0cb74f | 0 | — | — | — | | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 68 | 7833.8 | 0.0 | 177.4 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | review_pr | 0 | — | — | — | | resolve_review | 0 | — | — | — | | **Total** | **68** | 88226.4 | 6772.4 | 2175.4 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | sonnet | 6 | 46.4k | 107.8k | 3.6M | 356.4k | 59m 21s | | opus[1m] | 1 | 78 | 23.7k | 1.8M | 104.1k | 13m 39s | | MiniMax-M3 | 3 | 1.7M | 16.4k | 617.7k | 0 | 12m 28s | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3bfdf85 commit fc97982

5 files changed

Lines changed: 185 additions & 9 deletions

File tree

src/autoskillit/server/tools/tools_kitchen.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,23 @@ def _kitchen_failure_envelope(
9999

100100
def _recipe_validation_error_response(name: str, result: dict[str, Any]) -> str:
101101
_errs: list[str] = result.get("errors", [])
102-
_error_detail = "; ".join(_errs[:3]) if _errs else "unknown structural error"
102+
_error_findings = [
103+
s
104+
for s in result.get("suggestions", [])
105+
if isinstance(s, dict) and s.get("severity") == "error"
106+
]
107+
_finding_strs = [f"[{f.get('rule', '')}] {f.get('message', '')}" for f in _error_findings]
108+
if _errs and _finding_strs:
109+
_error_parts = _errs[:2] + _finding_strs[:1]
110+
else:
111+
_error_parts = (_errs + _finding_strs)[:3]
112+
_error_detail = "; ".join(_error_parts) if _error_parts else "unknown structural error"
113+
_label = "structural validation" if (_errs and not _error_findings) else "validation"
103114
return json.dumps(
104115
{
105116
"success": False,
106117
"kitchen": "failed",
107-
"user_visible_message": (
108-
f"Recipe '{name}' failed structural validation: {_error_detail}"
109-
),
118+
"user_visible_message": (f"Recipe '{name}' failed {_label}: {_error_detail}"),
110119
"error": f"Recipe '{name}' failed validation: {_error_detail}",
111120
"stage": "recipe_validation",
112121
"errors": _errs,

tests/contracts/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Protocol satisfaction, package gateway, and skill contract compliance tests.
3939
| `test_github_ops.py` | Contract tests: GitHub operation semantics in SKILL.md files |
4040
| `test_hook_bridge_coverage.py` | REQ-BRIDGE-001: quota guard hook config bridge must produce exactly the keys that resolve_quota_settings() reads |
4141
| `test_implement_experiment_contracts.py` | Contract tests for implement-experiment SKILL.md — test infrastructure requirements |
42+
| `test_validation_error_envelope_coverage.py` | REQ-ENVELOPE-001: validation error envelope must surface all error channels from compute_recipe_validity |
4243
| `test_input_type_semantic_correctness.py` | Cross-validate skill_contracts.yaml path input types against SKILL.md content |
4344
| `test_instruction_surface.py` | Contract tests: every instruction surface must carry the pipeline tool restriction |
4445
| `test_issue_body_discipline.py` | Cross-skill contract: no SKILL.md may append validation summaries to issue bodies |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""REQ-ENVELOPE-001: _recipe_validation_error_response must surface all error channels
2+
from compute_recipe_validity (structural errors + semantic/contract findings).
3+
4+
Mirrors the test_hook_bridge_coverage pattern: when a new error channel is added to
5+
LoadRecipeResult, this test forces the envelope to surface it.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
12+
import pytest
13+
14+
pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small]
15+
16+
17+
def test_envelope_surfaces_both_structural_and_semantic_channels():
18+
"""Envelope must reference at least one item from errors AND from suggestions."""
19+
from autoskillit.server.tools.tools_kitchen import _recipe_validation_error_response
20+
21+
result = {
22+
"valid": False,
23+
"errors": ["structural problem"],
24+
"suggestions": [
25+
{
26+
"severity": "error",
27+
"rule": "semantic-rule",
28+
"message": "semantic problem",
29+
"step": "s",
30+
},
31+
],
32+
}
33+
response = json.loads(_recipe_validation_error_response("demo", result))
34+
assert "structural problem" in response["user_visible_message"]
35+
assert "semantic-rule" in response["user_visible_message"]

tests/infra/test_schema_version_convention.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,11 @@ def _is_yaml_dump(node: ast.expr) -> bool:
119119
# _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system)
120120
("src/autoskillit/server/_lifespan.py", 87),
121121
# tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay
122-
("src/autoskillit/server/tools/tools_kitchen.py", 160),
123-
("src/autoskillit/server/tools/tools_kitchen.py", 179),
124-
("src/autoskillit/server/tools/tools_kitchen.py", 213),
125-
("src/autoskillit/server/tools/tools_kitchen.py", 863),
126-
("src/autoskillit/server/tools/tools_kitchen.py", 923),
122+
("src/autoskillit/server/tools/tools_kitchen.py", 169),
123+
("src/autoskillit/server/tools/tools_kitchen.py", 188),
124+
("src/autoskillit/server/tools/tools_kitchen.py", 222),
125+
("src/autoskillit/server/tools/tools_kitchen.py", 872),
126+
("src/autoskillit/server/tools/tools_kitchen.py", 932),
127127
# tools_pipeline_tracker.py — tracker_data dict
128128
("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166),
129129
# tools_status.py — mcp_data dict

tests/server/test_tools_kitchen_envelope.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,3 +883,134 @@ def test_every_return_path_parses_as_json_and_has_boolean_success(stage):
883883

884884
envelope = json.loads(_kitchen_failure_envelope(RuntimeError("test"), stage=stage))
885885
assert isinstance(envelope["success"], bool)
886+
887+
888+
def test_recipe_validation_error_response_surfaces_semantic_errors():
889+
"""_recipe_validation_error_response must merge error-severity suggestions
890+
into the user-visible message when errors is empty."""
891+
from autoskillit.server.tools.tools_kitchen import _recipe_validation_error_response
892+
893+
result = {
894+
"valid": False,
895+
"errors": [],
896+
"suggestions": [
897+
{
898+
"severity": "error",
899+
"rule": "backend-incompatible-skill",
900+
"message": "Step 'deploy' uses skill 'merge_worktree'",
901+
"step": "deploy",
902+
},
903+
],
904+
}
905+
response = json.loads(_recipe_validation_error_response("demo", result))
906+
assert response["success"] is False
907+
assert response["kitchen"] == "failed"
908+
assert response["stage"] == "recipe_validation"
909+
assert "unknown structural error" not in response["user_visible_message"]
910+
assert "backend-incompatible-skill" in response["user_visible_message"]
911+
assert "merge_worktree" in response["error"]
912+
913+
914+
@pytest.mark.parametrize(
915+
"result,expected_substring",
916+
[
917+
(
918+
{"valid": False, "errors": ["schema violation"], "suggestions": []},
919+
"schema violation",
920+
),
921+
(
922+
{
923+
"valid": False,
924+
"errors": [],
925+
"suggestions": [
926+
{
927+
"severity": "error",
928+
"rule": "rule-x",
929+
"message": "bad step",
930+
"step": "s",
931+
}
932+
],
933+
},
934+
"rule-x",
935+
),
936+
(
937+
{
938+
"valid": False,
939+
"errors": [],
940+
"suggestions": [
941+
{
942+
"severity": "error",
943+
"rule": "contract-y",
944+
"message": "stale",
945+
"step": "c",
946+
}
947+
],
948+
},
949+
"contract-y",
950+
),
951+
],
952+
)
953+
def test_validation_error_envelope_always_names_cause(result, expected_substring):
954+
"""Envelope must always name a cause when valid=False, never 'unknown structural error'."""
955+
from autoskillit.server.tools.tools_kitchen import _recipe_validation_error_response
956+
957+
response = json.loads(_recipe_validation_error_response("demo", result))
958+
assert response["success"] is False
959+
assert response["kitchen"] == "failed"
960+
assert response["stage"] == "recipe_validation"
961+
assert "unknown structural error" not in response["user_visible_message"]
962+
assert expected_substring in response["user_visible_message"]
963+
964+
965+
@pytest.mark.anyio
966+
async def test_open_kitchen_fails_on_semantic_errors_only(tmp_path, monkeypatch):
967+
"""open_kitchen must surface semantic error findings when errors=[]."""
968+
monkeypatch.chdir(tmp_path)
969+
mock_ctx = _make_mock_ctx()
970+
mock_ctx.enable_components = AsyncMock()
971+
mock_ctx.recipes = MagicMock()
972+
mock_ctx.recipes.load_and_validate.return_value = {
973+
"content": "name: demo\nsteps:\n do:\n tool: run_cmd\n",
974+
"valid": False,
975+
"errors": [],
976+
"suggestions": [
977+
{
978+
"severity": "error",
979+
"rule": "backend-incompatible-skill",
980+
"message": "Step 'deploy' uses skill 'merge_worktree'",
981+
"step": "deploy",
982+
},
983+
],
984+
}
985+
mock_ctx.config.migration.suppressed = []
986+
987+
with patch("autoskillit.server._get_ctx", return_value=mock_ctx):
988+
with patch("autoskillit.server.logger"):
989+
with patch(
990+
"autoskillit.server.tools.tools_kitchen._prime_quota_cache", new=AsyncMock()
991+
):
992+
with patch("autoskillit.server.tools.tools_kitchen._write_hook_config"):
993+
from autoskillit.server.tools.tools_kitchen import open_kitchen
994+
995+
result_str = await open_kitchen(name="demo", ctx=mock_ctx)
996+
997+
parsed = json.loads(result_str)
998+
assert parsed["success"] is False
999+
assert "unknown structural error" not in parsed["user_visible_message"]
1000+
assert "backend-incompatible-skill" in parsed["user_visible_message"]
1001+
1002+
1003+
def test_recipe_validation_error_response_handles_malformed_suggestions():
1004+
"""_recipe_validation_error_response must not crash on suggestions missing rule/message."""
1005+
from autoskillit.server.tools.tools_kitchen import _recipe_validation_error_response
1006+
1007+
result = {
1008+
"valid": False,
1009+
"errors": [],
1010+
"suggestions": [
1011+
{"severity": "error", "step": "some-step"},
1012+
],
1013+
}
1014+
response = json.loads(_recipe_validation_error_response("demo", result))
1015+
assert "unknown structural error" not in response["user_visible_message"]
1016+
assert response["success"] is False

0 commit comments

Comments
 (0)