# 0239 — A list written as text is refused for every string-or-array field

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0239-coerce-refuses-a-text-list-for-every-string-array-union.md

---
# 0239 — A list written as text is refused for every string-or-array field

**Status:** Accepted
**Date:** 2026-10-05

Amends ADR 0218. Invariants I2, I3, I8 and I13.

## Context

A field typed `z.union([z.string(), z.array(z.string())])` takes one value or a list. When a model
or a shell sends the list as JSON text, `'["a.png"]'`, the string member matches, and the value is
read as one wrong name. The shared coercion keeps strings in a string-bearing union on purpose:
parsing JSON first would turn identifiers such as `"123"` or `"null"` into another branch.

ADR 0218 closed this for one tool: the `view_file` input schema refused a list written as text. Every
other string-or-array field of every consumer contract kept the silent misread, and the refusal
lived in one tool's schema instead of in the mechanism that decides how strings are read.

## Decision

`coerceValue` (`packages/core/src/tools/schema/coerce.ts`) owns the refusal. When a union has a
string member, the string is kept as a string, except that a string which parses as a JSON array
the union's array member accepts is refused by name:
`<path> is a list written as text — pass the array itself, not JSON written as text`. The path is
the field's path at any depth. The refusal is a validation error on every surface that coerces
arguments: managed MCP, agent and CLI tools and raw MCP through `coerceJsonArgs`.

A string that is not a list the array member accepts stays one plain value: `[preview].png`, a
truncated `[1,2`, a list of objects for a string array, a list longer than the array's `max`.

`view_file` has no private check. Its schema is a plain string-or-array, and the shared rule covers
it. ADR 0218's decision for `view_file` — a mistaken list is refused before any IO, with a hint, and
the shared coercion does not parse it into an argument — still holds; where the refusal is made
changes.

## Consequences

Parsing `ViewFileInputSchema` directly no longer refuses a list written as text; the refusal happens
in `coerceJsonArgs`, which every tool surface runs. A list written as text as an *element* of a
string array is not seen by the rule, because the element is a plain `z.string()` with no union.

A consumer whose contracts hold a string-or-array field starts refusing the text form that was read
as one name. The migration is to send the array. The change is recorded as a breaking item for
`stitchkit/tools` and `stitchkit/cli` with its migration in `docs/guide/upgrading.md`.

## Verification

`tool-extensions.test.ts` covers the refusal by name at any depth, the strings that stay one plain
value, and the validation error on every tool surface. `managed-view-file.test.ts` runs `view_file`
through the shared rule.
