# `ToolResult.output` validation gate

A Pydantic `@field_validator` on `ToolResult.output` that catches one
specific antipattern. It is **not** a real error path — when it fires, it
means a tool's output payload looks like a known silent-failure shape and
we are loudly refusing to let it through.

- **Lives in:** [models.py](models.py) → `ToolResult._reject_stringified_structures`
- **Exception class:** `ToolOutputContractError` (subclass of `ValueError`)
- **Triggers a loud red banner** via `vcprint` immediately before raising,
  with the full `output` dict, who the gate is, what it caught, and how to
  fix it. The exception message is the short pointer-to-the-banner version.

---

## What it catches

The gate walks every string field of `ToolResult.output` and rejects any
string that:

1. starts with `{` or `[`, AND
2. ends with the matching `}` / `]`, AND
3. parses with `json.loads` or `ast.literal_eval` as `dict` or `list`.

If all three are true, the gate raises.

## Why this antipattern is dangerous

The shape we are defending against:

```python
try:
    result = do_work()
except Exception as e:
    failure = {"success": False, "error": str(e)}
    return ToolResult(
        success=True,                # ← outer says OK
        output={"result": str(failure)},  # ← actual failure stringified into a scalar
    )
```

When this happens the orchestrator records the call as OK, the telemetry
sinks log OK, the model receives a JSON-looking string in `output["result"]`,
and the operator only finds out hours later that "the tool ran fine but
the work never happened." The whole class of bug is invisible without a
boundary check — hence this gate.

A defense-in-depth flip lives in the executor too
([executor.py](executor.py) → "Defense-in-depth: embedded-error-envelope
detection") for the case where the outer `success=True` envelopes a
NATIVE dict with `success=False` inside it. The two gates are complementary.

## How to fix when the gate fires

The banner tells you the offending field name. Two options:

**Option 1 — return the native object (preferred).**
If the tool meant to return structured data, put the native `dict` / `list`
into the field directly:

```python
return ToolResult(success=True, output={"data": parsed_obj})    # ✓
# not
return ToolResult(success=True, output={"data": json.dumps(parsed_obj)})  # ✗ gate fires
```

The provider serializes tool results to JSON when handing them to the
model anyway, so the model sees the same payload either way. One fewer
escape layer is better for token cost and for downstream consumers.

**Option 2 — restructure the field.**
If the field is genuinely supposed to be an opaque string blob that
happens to look like JSON, refactor so the tool returns the parsed
structure under a typed key. There is no per-field opt-out by design.

## Why no per-field opt-out

We considered an allowlist of field names (`raw_*`, `payload`, etc.) that
would skip the parse check. We rejected it because:

- Allowlists rot — every new escape-hatch name dilutes the gate.
- The antipattern is easy to land by accident; a too-easy bypass means
  the bug class comes back within months.
- "Return the native structure" is always a valid fix; opacity is rarely
  a real requirement.

If you hit a case where Option 1 and Option 2 both feel wrong, that is
the conversation to have — not a quiet workaround.

## What was the historical bug?

`ctx_get(mode='full')` used to return `output["content"] = obj.content_as_str()`
where `content_as_str()` would `json.dumps(...)` any dict/list content. The
result was a perfectly-formed JSON string in `output["content"]` — which
parsed cleanly back into a dict and tripped this gate on every call to
typed-`json` slots (which is most slots). Fixed by having `ctx_get` return
the native dict/list in `output["content"]` when `obj.content` is a dict
or list. See [implementations/ctx.py](implementations/ctx.py) `mode == "full"`.

## When the gate fires, what does the operator see?

1. A loud red banner in stdout / logs (full output dict, no truncation,
   self-identifying — who, where, why, what, how to fix).
2. A short clean error message on the tool result: `[MATRX GATE] ToolResult.output['<key>'] is a stringified <type>. See the red banner …`
3. `error_type="matrx_validation_gate"` on the `ToolError` (when the
   tool's except-block uses `_extract_gate_message`) — easy to grep
   for in `cx_tool_trace` and trace files.

## Adding a similar gate elsewhere

This doc is the canonical home of the contract (the root
[CLAUDE.md](../../../../CLAUDE.md) "Internal validation gates ≠ real errors"
section points here). The five rules:

1. **Loud red banner via `vcprint`** — full state, self-identifying:
   who I am (file + function), my job (what I catch and why), what
   I caught in this specific case, how to fix, docs link.
2. **Short clean exception** that points to the banner (no Pydantic
   vomit, no preview vomit, no `errors.pydantic.dev` URL).
3. **No traceback** when the gate itself is the cause — a traceback
   into Pydantic internals wastes reader time. Tools catching the
   exception should detect the gate's exception class and emit the
   short message verbatim instead of wrapping it in `f"X failed: {e}"`.
4. **A dedicated `error_type`** (e.g. `matrx_validation_gate`) on
   the surfaced `ToolError` / response so trace logs separate gate
   trips from real failures with one grep.
5. **A short `.md` next to the gate** explaining what it catches,
   why, and how to fix — referenced from the validator docstring
   and the banner.

The unwrap helper pattern lives in
[`implementations/ctx.py::_extract_gate_message`](implementations/ctx.py)
— copy it (one function, ~15 lines) into any tool that needs to detect
a `ToolOutputContractError` (or any future gate exception) inside a
Pydantic `ValidationError` and surface the inner clean message.
