srr84/agent-data-layer
0
1"""GATE-d — malformed handling (Validation Plan §2 gate (d) + §2.1).2 3DoD (Implementation Plan §4 / Validation Plan §2): 100% of malformed calls4-> a typed ValidationError(field, expected, got), no partial execution.5Boundary/fuzz over EVERY tool's params, all violation classes (FR-2):6 - wrong type7 - missing required param8 - extra / unknown param key (extra="forbid")9 - out-of-enum value10 - bad scalar format11 - T6 start > end (degenerate window)12 13Each must yield ValidationError specifically — never a crash (a different14exception type), never None, never a silent pass (a returned15ValidatedToolCall). The `got` summary must be a typed descriptor, never the16raw offending value (F6S-8 / TechSpec §4).17 18Also asserts §2.1 routing-edge disjointness from the gate-(d) side and that19gate (c) and gate (d) never both claim one input.20 21spec traceability (audit map — safe to skip):22- Validation Plan §2 gate (d) (`test_malformed_typed_error`, STRUCTURAL 100%);23 §2.1 routing edges (extra key / wrong value -> ValidationError)24- LLD §2 line 123 (ordered-disjoint decision rule); §3 (T6 start<=end, F-8);25 §6 F6S-8 (`got` typed summary)26- TechSpec §3 (per-tool schemas; extra="forbid"); §4 (`got` never a raw echo)27- FuncSpec FR-2, FR-3; Charter INV-C1/INV-C3; R-B-1 (T4 condition 5-key set)28"""29 30from __future__ import annotations31 32import pytest33 34from agent_data_layer.contracts.errors import (35 RawQueryRejected,36 UnregisteredTool,37 ValidationError,38)39from agent_data_layer.contracts.tools import ToolCall, ValidatedToolCall40from agent_data_layer.validator import validate41 42TS = "2026-01-01T00:00:00Z"43TS2 = "2026-02-01T00:00:00Z"44 45# --- wrong type (a typed/enum slot fed a non-string or wrong scalar) ------46WRONG_TYPE_CALLS: tuple[ToolCall, ...] = (47 ToolCall(tool_name="get_stock_state",48 params={"store_id": 123, "product_id": "pr-001", "as_of": TS}),49 ToolCall(tool_name="get_price",50 params={"store_id": "st-001", "product_id": 7, "as_of": TS}),51 ToolCall(tool_name="get_compliance_flag",52 params={"store_id": "st-001", "product_id": "pr-001", "as_of": True}),53 ToolCall(tool_name="count_stores_with_condition",54 params={"condition": 5, "as_of": TS}),55 ToolCall(tool_name="list_products_in_state",56 params={"store_id": "st-001", "state": None, "as_of": TS}),57 ToolCall(tool_name="count_events_in_window",58 params={"store_id": "st-001", "state": "ON_SHELF", "start": 0, "end": TS}),59)60 61# --- missing required param (each tool, each required slot) ---------------62MISSING_REQUIRED_CALLS: tuple[ToolCall, ...] = (63 ToolCall(tool_name="get_stock_state", params={}),64 ToolCall(tool_name="get_stock_state",65 params={"store_id": "st-001", "as_of": TS}), # missing product_id66 ToolCall(tool_name="get_price",67 params={"product_id": "pr-001", "as_of": TS}), # missing store_id68 ToolCall(tool_name="get_compliance_flag",69 params={"store_id": "st-001", "product_id": "pr-001"}), # missing as_of70 ToolCall(tool_name="count_stores_with_condition", params={"as_of": TS}), # no condition71 ToolCall(tool_name="list_products_in_state",72 params={"store_id": "st-001", "state": "ON_SHELF"}), # missing as_of73 ToolCall(tool_name="count_events_in_window",74 params={"store_id": "st-001", "state": "ON_SHELF", "start": TS}), # no end75)76 77# --- extra / unknown param key (extra="forbid") --------------------------78EXTRA_KEY_CALLS: tuple[ToolCall, ...] = (79 ToolCall(tool_name="get_stock_state",80 params={"store_id": "st-001", "product_id": "pr-001", "as_of": TS, "bogus": 1}),81 ToolCall(tool_name="get_price",82 params={"store_id": "st-001", "product_id": "pr-001", "as_of": TS, "limit": 10}),83 ToolCall(tool_name="count_stores_with_condition",84 params={"condition": "OUT_OF_STOCK", "as_of": TS, "extra": "x"}),85 ToolCall(tool_name="count_events_in_window",86 params={"store_id": "st-001", "state": "ON_SHELF", "start": TS, "end": TS2, "k": 1}),87)88 89# --- out-of-enum value (well-shaped plain token, not an expression) -------90OUT_OF_ENUM_CALLS: tuple[ToolCall, ...] = (91 ToolCall(tool_name="count_stores_with_condition",92 params={"condition": "ON_FIRE", "as_of": TS}),93 # R-B-1: the M0 StoreCondition HAS_* names are NOT in the T4 by_condition94 # 5-key set, so HAS_OUT_OF_STOCK is out-of-domain here.95 ToolCall(tool_name="count_stores_with_condition",96 params={"condition": "HAS_OUT_OF_STOCK", "as_of": TS}),97 ToolCall(tool_name="list_products_in_state",98 params={"store_id": "st-001", "state": "OVERSTOCK", "as_of": TS}),99 ToolCall(tool_name="count_events_in_window",100 params={"store_id": "st-001", "state": "out_of_stock", "start": TS, "end": TS2}),101)102 103# --- bad scalar format (well-shaped string, fails the pinned pattern) -----104BAD_FORMAT_CALLS: tuple[ToolCall, ...] = (105 ToolCall(tool_name="get_stock_state",106 params={"store_id": "STORE-1", "product_id": "pr-001", "as_of": TS}),107 ToolCall(tool_name="get_stock_state",108 params={"store_id": "st-1", "product_id": "pr-001", "as_of": TS}),109 ToolCall(tool_name="get_price",110 params={"store_id": "st-001", "product_id": "PXYZ", "as_of": TS}),111 ToolCall(tool_name="get_price",112 params={"store_id": "st-001", "product_id": "pr-1", "as_of": TS}),113 ToolCall(tool_name="get_compliance_flag",114 params={"store_id": "st-001", "product_id": "pr-001", "as_of": "2026-01-01"}),115 ToolCall(tool_name="count_events_in_window",116 params={"store_id": "st-001", "state": "ON_SHELF", "start": "yesterday", "end": TS}),117)118 119# --- T6 start > end (degenerate window; well-shaped wrong VALUE, F-8) ------120WINDOW_ORDER_CALLS: tuple[ToolCall, ...] = (121 ToolCall(tool_name="count_events_in_window",122 params={"store_id": "st-001", "state": "ON_SHELF", "start": TS2, "end": TS}),123 ToolCall(tool_name="count_events_in_window",124 params={"store_id": "st-001", "state": "OUT_OF_STOCK",125 "start": "2026-12-31T23:59:59Z", "end": "2026-01-01T00:00:00Z"}),126)127 128ALL_MALFORMED: tuple[ToolCall, ...] = (129 WRONG_TYPE_CALLS130 + MISSING_REQUIRED_CALLS131 + EXTRA_KEY_CALLS132 + OUT_OF_ENUM_CALLS133 + BAD_FORMAT_CALLS134 + WINDOW_ORDER_CALLS135)136 137 138@pytest.mark.parametrize("call", WRONG_TYPE_CALLS)139def test_gate_d_wrong_type(call: ToolCall) -> None:140 assert isinstance(validate(call), ValidationError)141 142 143@pytest.mark.parametrize("call", MISSING_REQUIRED_CALLS)144def test_gate_d_missing_required(call: ToolCall) -> None:145 assert isinstance(validate(call), ValidationError)146 147 148@pytest.mark.parametrize("call", EXTRA_KEY_CALLS)149def test_gate_d_extra_key(call: ToolCall) -> None:150 assert isinstance(validate(call), ValidationError)151 152 153@pytest.mark.parametrize("call", OUT_OF_ENUM_CALLS)154def test_gate_d_out_of_enum(call: ToolCall) -> None:155 assert isinstance(validate(call), ValidationError)156 157 158@pytest.mark.parametrize("call", BAD_FORMAT_CALLS)159def test_gate_d_bad_scalar_format(call: ToolCall) -> None:160 assert isinstance(validate(call), ValidationError)161 162 163@pytest.mark.parametrize("call", WINDOW_ORDER_CALLS)164def test_gate_d_window_start_after_end(call: ToolCall) -> None:165 result = validate(call)166 assert isinstance(result, ValidationError)167 assert result.field == "start"168 169 170def test_gate_d_all_malformed_yield_typed_error_never_crash_or_pass() -> None:171 """100% of malformed calls -> typed ValidationError.172 173 Any OTHER exception type is a crash; None is a silent miss; a returned174 ValidatedToolCall is a silent pass. All three fail the gate here, so it175 cannot be satisfied by a non-typed outcome.176 """177 typed = 0178 for call in ALL_MALFORMED:179 try:180 result = validate(call)181 except Exception as exc: # noqa: BLE001 - a crash is a gate failure182 pytest.fail(183 f"GATE-d CRASH: {call.tool_name!r} raised "184 f"{type(exc).__name__}: {exc}"185 )186 assert result is not None, f"GATE-d None: {call.tool_name!r}"187 assert not isinstance(result, ValidatedToolCall), (188 f"GATE-d SILENT PASS: malformed {call.tool_name!r} accepted"189 )190 assert isinstance(result, ValidationError), (191 f"GATE-d MISROUTE: {call.tool_name!r} -> {type(result).__name__}, "192 f"expected ValidationError"193 )194 # F6S-8: the `got` summary is a typed descriptor, not the raw value.195 assert isinstance(result.got, str) and result.got196 typed += 1197 assert typed == len(ALL_MALFORMED)198 199 200def test_gate_d_got_is_typed_summary_never_raw_echo() -> None:201 """`got`/`expected` never echo a distinctive raw value (F6S-8)."""202 marker = "99zz-INJECT-7"203 result = validate(204 ToolCall(205 tool_name="get_stock_state",206 params={"store_id": marker, "product_id": "pr-001", "as_of": TS},207 )208 )209 assert isinstance(result, ValidationError)210 assert marker not in result.got211 assert marker not in result.expected212 213 214# --- §2.1 routing-edge disjointness (gate (c) vs gate (d)) ---------------215 216 217def test_gate_cd_disjoint_extra_key_is_validation_not_raw_query() -> None:218 """An extra PARAM key routes to gate (d) (ValidationError), not (c)."""219 result = validate(220 ToolCall(221 tool_name="get_stock_state",222 params={"store_id": "st-001", "product_id": "pr-001", "as_of": TS, "junk": 1},223 )224 )225 assert isinstance(result, ValidationError)226 assert not isinstance(result, RawQueryRejected)227 228 229def test_gate_cd_disjoint_well_shaped_wrong_value_is_validation() -> None:230 """A well-shaped wrong VALUE routes to gate (d), not (c)/(unregistered)."""231 result = validate(232 ToolCall(233 tool_name="count_stores_with_condition",234 params={"condition": "ON_FIRE", "as_of": TS}, # plain out-of-enum token235 )236 )237 assert isinstance(result, ValidationError)238 assert not isinstance(result, (RawQueryRejected, UnregisteredTool))239 