# Contrato de respuesta de las tools

> Doctrina de contribución para lo que las tools **devuelven**.
> Es el par simétrico de `schema-policy.md`: aquella gobierna el schema de entrada publicado por `tools/list`; esta gobierna el schema de salida y la forma del resultado de `tools/call`.
> Ejecutable: `tests/forcing-functions/published-schema-contract.test.ts` verifica la regla de schemas sobre entradas **y** salidas.

Issues: #104 (envelope sin `message`), #167 (post-condición), #172 (identidad de instancia), #181 (proyección y cobertura).

## 0. El problema que resuelve

Hoy `ToolBase.success(data)` serializa `data` a `content[0].text` con `JSON.stringify(data, null, 2)` y nada más. De ahí salen cuatro defectos que se reportaron por separado y son el mismo defecto:

| Síntoma reportado | Issue | Qué falta en la respuesta |
|---|---|---|
| Frases enlatadas (`"Template permanently deleted"`) que gastan tokens y a veces mienten | #104 | Nada: sobra prosa |
| Tras escribir, el agente no sabe en qué estado quedó el recurso; se infiere con `channels-publish --dry_run` | #167 | La post-condición |
| Dos instancias con los mismos ~80 nombres de tool; nada dice a qué cuenta se pegó | #172 | La identidad de la instancia |
| Una búsqueda vacía no distingue "no hay match" de "no hay nada donde buscar"; y leer 314 entradas cuesta 2,05 M tokens | #181 | La cobertura y la proyección |

Los cuatro se arreglan en el mismo lugar —el único punto por donde salen las 308 respuestas del catálogo— y por eso se diseñan juntos.

## 1. El envelope

Toda tool migrada devuelve `structuredContent` con esta forma, tipada por su `outputSchema`:

```jsonc
{
  "platform": { "name": "langflow", "host": "langflow.modyo.cloud" },  // siempre
  "data":     { /* el resultado, con forma propia de cada tool */ },   // siempre
  "resource": { "type": "space", "id": 636, "name": "Beneficios" },    // cuando hay una entidad resuelta
  "state":    { "returned": 100, "total": 314, "complete": false }     // cuando hay algo verificable que declarar
}
```

Cuatro claves, ninguna opcional por comodidad: `platform` y `data` son obligatorias; `resource` y `state` están presentes **siempre que apliquen**, y cuándo aplican lo fija este documento, no cada tool.

**No hay campo `message`.** Es la decisión de #104: lo humano-legible vive en `content[].text` (§5), no duplicado dentro del JSON. Una tool migrada que quiera "explicar" su resultado está mal diseñada — la explicación la redacta el modelo a partir de `data`, `resource` y `state`.

### 1.1 `platform` — identidad de la instancia (#172)

```ts
platform: { name: string, host: string }
```

`host` es el hostname de `MODYO_URL` (`langflow.modyo.cloud`), no la URL completa: el esquema y la barra final no desambiguan nada y sí cuestan tokens.

**Va en todas las respuestas, no solo en las de escritura.** #172 admite la variante barata ("al menos escrituras y errores") y la descartamos: una lectura contra la cuenta equivocada no es inocua, es la premisa falsa sobre la que el agente después escribe. El caso testigo del issue —confundir los sitios 5000 y 5004— es de lectura.

El costo se nombra en vez de esconderse: ~18–20 tokens por respuesta (§7 lo mide). Es el término que el epic de footprint (#58) tiene que poder auditar, y por eso `host` es hostname y no URL.

**Default informativo.** Hoy `MODYO_PLATFORM_NAME` cae a la constante `"default"`, que no identifica nada — dos instancias distintas se llaman igual, que es exactamente el fallo de #172. Cuando la variable no está seteada, `name` deriva del host (primera etiqueta del hostname: `langflow.modyo.cloud` → `langflow`). El `slug` interno del `PlatformConfig` sigue siendo `"default"`: es una clave de lookup, no una etiqueta.

Una IP se devuelve entera: recortar `127.0.0.1` a `"127"` reproduce el defecto que este default viene a arreglar. Lo encontró la sonda del piloto, que apunta a un servidor local.

### 1.2 `data` — el resultado

La forma propia de cada tool. Dos reglas:

- **Sin prosa.** Counts, arrays, `null`, valores. Un array vacío ya significa conjunto vacío; `null` ya significa no-encontrado. Nada de `empty: true`, `notFound: true` ni `reason: "no_match"`: son pre-interpretaciones nuestras de una decisión que le toca al modelo.
- **Sin banderas derivables.** Si el dato ya está en `state`, no se repite en `data`.

### 1.3 `resource` — la entidad resuelta y afectada (#181 ítem 20, #167)

```ts
resource: { type: string, uuid?: string, id?: number, slug?: string, path?: string, name?: string }
```

`type` siempre; de los identificadores, **todos los que la entidad tenga** — `uuid` siempre que exista, porque es el único estable a través de una publicación (regla del servidor: los ids numéricos de widget/template/entry cambian al publicar).

Presente cuando la llamada resolvió u operó sobre **una** entidad identificable, y ahí incluye el caso que #181 llama *eco de la entidad resuelta*: cuando el identificador entró como nombre o slug y el servidor lo resolvió por búsqueda heurística (`resolveSpace()` prueba UID y después nombre; `channels-widgets-code-edit` resuelve query-first, #146), **no devolver qué resolvió es pedirle al agente que confíe a ciegas**.

En una lectura de colección, la entidad resuelta es el **contenedor** sobre el que se listó, no cada elemento: `content-entries-list` sobre el espacio 636 devuelve `resource: { type: "space", id: 636, name: "…" }`. Los elementos ya están en `data`.

Cuando no hay una entidad única (operaciones masivas, listados de nivel raíz), `resource` se omite y el conteo vive en `state`.

### 1.4 `state` — lo verificable (#167 en escrituras, #181 ítem 19 en lecturas)

`state` es la respuesta a "¿qué puedo afirmar sobre esta llamada sin hacer otra?". Tiene dos caras según la operación, y es la misma idea en ambas: **la respuesta declara su propia cobertura o su propio efecto, en vez de dejar que se infiera.**

Las dos caras **se publican por separado** en el `outputSchema`, no como un objeto único con los once campos. Es una corrección que impuso el piloto, por dos razones que apuntan igual: una lectura que declara `live_version` le ofrece al modelo un campo que nunca va a llegar, y el objeto único costaba 130 tokens de `outputSchema` por tool —~10.700 fijos por sesión a 82 tools— contra 79 publicando solo la cara que aplica.

**Con una excepción, que impuso el rollout:** una tool `gated` —multi-acción con acciones de lectura, las 27 `*-manage`— devuelve legítimamente cobertura en su rama `list` y post-condición en su rama `update`. Publicarle una sola cara **hace fallar la validación del SDK en la otra rama**, y los tests unitarios no lo ven porque llaman a `execute()` sin pasar por esa validación. Esas tools publican las dos caras (~1.400 tokens en total sobre el catálogo: el precio correcto de no mentir sobre lo que la tool puede devolver).

La cara se deriva de **la misma clasificación que usa el gate read-only** (`readOnlyClass()`), no de `readOnlyHint` a secas: `read` → cobertura, `gated` → ambas, `mutation` → post-condición. La tool no inventa su `state`.

**En lecturas — cobertura.** Un resultado vacío tiene que ser autoexplicativo sin una segunda llamada:

```ts
state: {
  returned: number,     // elementos en data
  total?: number,       // tamaño del conjunto que cumple el filtro, si la API lo informa
  searched?: number,    // tamaño del corpus recorrido — #181 ítem 19
  page?: number, per_page?: number,
  complete: boolean,    // false si quedó algo afuera por paginación o por tope
}
```

`searched` es el que resuelve el incidente de #181: `channels-templates-search` devolvió vacío en el sitio 5004 y no se podía saber si la query falló o si el sitio no tenía templates. `searched: 0` lo resuelve en una llamada.

`complete: false` es la contrapartida del truncado silencioso de #113. **Toda respuesta que dejó algo afuera lo dice.** Esa es la regla; el resto de `state` es detalle.

#### La regla de `complete`: se afirma solo con evidencia

**`complete` significa "sé que cubrí todo", no "creo que sí".** La única evidencia que lo sostiene es:

1. el `meta` de la API —`total_entries` contra lo devuelto—, o
2. una paginación agotada por el propio servidor (`getAllTemplates` y equivalentes), o
3. un corpus que el servidor posee entero (un registro en memoria, un objeto anidado que la API devolvió completo).

**Sin evidencia, el campo se omite.** Es opcional en el schema y la omisión significa **"no sé"** — nunca "cubrí todo". Un `complete` ausente le dice al modelo que pida confirmación; un `complete: true` falso le dice que no hace falta, que es el daño que #113 causa.

El censo semántico de #206 encontró el anti-patrón que esta regla prohíbe, repetido en 17 sitios:

```ts
// ANTI-PATRÓN — no reproducir
const total = result.meta?.total_entries ?? spaces.length;
...
complete: spaces.length >= total,
```

Con `meta` presente compara la página contra el total real y dice la verdad. **Sin `meta`, `total` cae en `spaces.length` y la expresión se reduce a `spaces.length >= spaces.length`: `true` por construcción.** El `??` degrada al lado optimista exactamente en el caso en que el truncado es indetectable — afirma cobertura total justo cuando menos se sabe.

La forma correcta usa el helper del contrato, que no tiene rama en la que mienta:

```ts
import { completeFrom } from "@shared/index.js";

state: {
  returned: spaces.length,
  total,
  ...completeFrom(spaces.length, result.meta?.total_entries),
}
```

Lo mismo vale para el `complete: true` escrito literal sobre un endpoint que no expone `meta`: se omite. `channels-templates-manage` lo hace hoy en sus ramas de `/templates/layouts`, `/css` y `/js`, y **conserva** el `complete: true` de su rama de `getAllTemplates`, que sí agota la paginación. La diferencia entre esas dos ramas es la regla entera.

**En escrituras — post-condición.** Qué quedó, verificable, sin llamar a `channels-publish --dry_run`:

```ts
state: {
  status?: string,          // estado del recurso tal como quedó
  published?: boolean,
  live_version?: number,    // versión publicada, donde el recurso versione
  pending?: boolean,        // hay cambios sin publicar
  affected?: number,        // en operaciones masivas
}
```

Y la regla que le da sentido, la de #167 punto 2: **los valores persistidos van en `data`, releídos de la respuesta de la API, no ecoados de los parámetros de entrada.** Es lo que convierte el `"Image": "549895"` de #163 —un string literal persistido donde iba un asset, con la API respondiendo `"updated"` y las tres capas sin emitir un error— de corrupción silenciosa en un error visible en la misma llamada. Una respuesta que ecoa lo que le mandaron no verifica nada: certifica el pedido, no el hecho.

El `verify: z.boolean().default(false)` que hoy existe desparejo en algunas tools queda **subsumido**: la post-condición deja de ser opt-in. El parámetro se conserva donde existe, para no romper llamadores, y pasa a ser un no-op documentado durante el rollout.

## 2. `outputSchema`

Se declara junto al `inputSchema` en `registerTool`, con el mismo pipeline zod → JSON Schema del SDK (`toJsonSchemaCompat`, verificado en 1.29.0). No requiere bump del SDK.

**Declarar `outputSchema` es un compromiso duro.** Verificado contra el SDK (`server/mcp.js:186-206`): si una tool declara `outputSchema` y devuelve un resultado no-error **sin** `structuredContent`, el SDK responde `-32602 Output validation error` y el resultado llega al cliente como `isError: true`. Lo mismo si el `structuredContent` no valida. De ahí dos consecuencias operativas:

1. **La migración es por tool, nunca a medias.** `outputSchema` se declara en el mismo commit en que la tool empieza a emitir `structuredContent`. Las tools no migradas siguen con `success()` y sin `outputSchema`, y conviven sin problema.
2. **El schema de `data` es tolerante a la deriva de la API.** Los objetos que la tool **ecoa** de la API se tipan con `z.looseObject` (emite `additionalProperties: {}`): un campo nuevo del lado de Modyo agrega información, no rompe la tool. Los objetos que el servidor **construye** (`platform`, `resource`, `state`, y las proyecciones) se tipan con `z.object` estricto, porque de esos somos dueños.

Un schema estricto sobre datos ajenos convierte cualquier cambio upstream en una caída de disponibilidad. Esa es la razón de la asimetría, y es deliberada.

### 2.1 Lo que cuesta publicarlo

**Medido sobre las 82 tools migradas** (sonda JSON-RPC contra el build, no extrapolación):

| | tokens |
|---|---:|
| `tools/list` en `origin/main` | 106.910 |
| `tools/list` con las 82 tools migradas | **146.681** |
| diferencia | **+39.771 (+37,2%)** |
| — de eso, el envelope (`platform` + `resource` + `state` + `control`) | 25.294 (308 por tool) |
| — de eso, el `data` propio de cada tool | 9.831 (120 por tool) |
| — de eso, el andamiaje del schema (`type` / `required` / `$schema` / la clave `outputSchema`) | 4.521 |
| — de eso, el parámetro `fields` de `content-entries-list`, ajeno al `outputSchema` | 125 |

Las cuatro últimas filas suman exactamente la diferencia. Por slot del envelope: `state` 8.114 (99 por tool), `resource` 6.810 (83), `control` 6.132 (75), `platform` 4.238 (52).

**El número real más que duplica la proyección de la Fase 1** (+17.000 / +16%), y la diferencia tiene tres causas identificadas, ninguna evitable sin renunciar a algo del contrato:

1. **El slot `control`** no existía cuando se hizo la proyección: lo impuso el rollout (§4.1) y sin él las cancelaciones y el gate read-only fallan con `-32602`.
2. **Las 27 tools `gated` publican las dos caras de `state`** (§1.4), que es lo correcto y cuesta ~51 tokens extra cada una.
3. **El `data` de una tool multi-acción es grande**: 9.831 tokens en total, 120 por tool, porque §3.7 obliga a declarar en un solo objeto todos los campos que cualquiera de sus acciones puede devolver.

El trade sigue siendo el mismo, con los números corregidos: **+39.771 tokens una vez por sesión** —39.646 de ellos del `outputSchema`, el resto ajeno— contra un ahorro que va de **−16% a −42% en cada respuesta de lectura**. En una sesión de lectura intensiva se recupera rápido; en una de dos o tres llamadas, no. Es la decisión que el operador ya tomó con la cifra proyectada y que conviene revisar con la medida.

Lo que ya se hizo para bajarlo: publicar una sola cara de `state` donde aplica (§1.4), que lo recortó de 257 a 206 tokens por tool antes de que `control` lo subiera a 308.

**Dónde se paga ese costo, verificado el 2026-09-03.** Es costo de **cable, no de contexto**: en ningún cliente observado el `outputSchema` llega al contexto del modelo. Claude Code 2.1.259 mide Δ = 0 tokens —prompts byte a byte idénticos entre `main` y la rama, comprobado tanto con deferred loading como con `ENABLE_TOOL_SEARCH=false`—; Langflow 1.10/1.11.1/`main` lo guarda en el `metadata` del `StructuredTool` (`base/mcp/util.py:2435` / `:2785`) y nadie lo lee, así que LangChain lo descarta antes de cualquier adaptador de proveedor; y el SDK Python de `google-genai` no lo puebla (`_mcp_utils.py:48-65`). La única excepción conocida es el SDK **JS** `@google/genai`, que mapea `outputSchema` → `responseJsonSchema` sin flag (`dist/index.mjs:3827`, v2.21.0): ahí sí son **+28.008 tokens por request**, sin caché implícita. Hoy ninguna configuración conocida usa ese camino.

Esto es un hecho sobre **clientes concretos en fechas concretas, no una propiedad del protocolo**: MCP publica el `outputSchema` y cada cliente decide qué hace con él. Si cambia el ecosistema de clientes —o las versiones de arriba—, hay que volver a medirlo antes de apoyarse en esta conclusión.

### 2.2 Regla de schemas para salidas: más estricta que para entradas

**Sin `anyOf`/`oneOf` con más de una rama estructurada (objeto o array) a ninguna profundidad. Sin whitelist, sin excepciones.**

`schema-policy.md` §1 admite hoy una whitelist para las entradas porque hay deuda previa —#163, #157, #193, #194— y arrancarla de raíz es un rediseño en sí mismo. **En salidas no hay deuda previa: el contrato nace hoy.** Una excepción aquí no sería deuda heredada, sería deuda recién contraída, y el catálogo de lo que no hay que reproducir ya está escrito:

- #163 — Langflow 1.11 se queda con la primera rama de un `anyOf`; la unión de 17 variantes de campo colapsa a `StringField` y persiste `"549895"` sin error.
- #157 — la function-calling de Gemini rechaza la request entera ante `oneOf`/`anyOf`.
- #193 — `validations.asset_types`, una unión de 4 objetos repetida en 12 rutas, que era un enum disfrazado.
- #194 — el idiom `array | {add, remove}` en tres rutas de dos tools.

Lo permitido es lo mismo que en entradas y por el mismo motivo: uniones de escalares (`z.union([z.string(), z.number()])`), y una rama estructurada más escalares o `null` (lo que emiten `.nullable()` y `.optional()`). Verificado sobre el serializador real: `z.string().nullable()` → `anyOf: [{string}, {null}]`, una sola rama estructurada; `z.unknown()` → `{}`, sin unión.

**Dónde esto roza a #194.** El idiom `array | {add, remove}` es de entrada, no de salida, y este rediseño **no lo toca**: su reemplazo canónico se decide una sola vez, en #163/#194, no tool por tool y no acá. Lo que sí hace este contrato es no reproducirlo: si una salida necesitara expresar "una lista o un delta", se publican los dos campos por separado. La whitelist de `KNOWN_STRUCTURAL_UNIONS` **no crece por este trabajo**.

### 2.3 Cómo se escribe

```ts
protected getOutputSchema() {
  return z.object({
    entries: z.array(z.looseObject({ uuid: z.string(), name: z.string() })),
  });
}
```

La tool declara **solo el schema de su `data`**. `ToolBase` lo envuelve con `platform`, `resource` y `state`, que son iguales para todas y no se reescriben 82 veces.

### 2.4 Reglas de salida verificadas (#206)

> Ejecutable: `tests/forcing-functions/output-schema-contract.test.ts` las verifica en cada PR y push.
> Género y doctrina de listas: `schema-policy.md`, que hace lo mismo para los `paramsSchema` de entrada.

Las dos reglas salen del punto 2 de §2 —tolerante donde se ecoa, estricto donde el servidor construye— y lo vuelven afirmable.

#### Regla 1 — una raíz propia se publica con `z.object`

**Regla.** Si la raíz del `data` la escribe el servidor clave por clave, se tipa con `z.object`. `z.looseObject` en la raíz se reserva para cuando el payload **es** un objeto ajeno.

**Por qué.** `looseObject` compra algo solo si la API puede meter claves en ese nivel. La deriva ajena aterriza **dentro de los valores de las hojas**, nunca en una raíz que enumeramos nosotros: un campo nuevo del lado de Modyo aparece dentro de `entries[].algo`, no como una sexta clave de un `{ source, target, itemsCopied }` que escribimos a mano. Ahí `additionalProperties: {}` no es tolerancia, es ruido que declara una libertad que nadie tiene.

No es cosmético. El barrido de #206 encontró 46 tools así, y el mismo descuido escondía dos defectos reales: `widgets-scaffold` declaraba `gitInit: z.boolean()` sobre un `"initialized"` —todo scaffold exitoso llegaba al cliente como `-32602`— y `content-jobs-manage` publicaba un `job: z.looseObject({})` que pasaba la forcing function de #104 **por no declarar nada**, mientras `progress.message` viajaba igual por el cable.

**Tres procedencias, no dos.** La regla se afirma sobre de dónde sale el objeto, y el escáner distingue:

| procedencia | qué es | raíz |
|---|---|---|
| `own` | el servidor la escribe clave por clave y las claves son enumerables | `z.object` |
| `echoed` | el payload es un objeto ajeno (`await repository.getX()`, un acceso a la respuesta, un doc del CDN) | `z.looseObject`, declarada en `ECHOED_ROOT_TOOLS` |
| `opaque` | la construye el servidor pero no se puede enumerar: un `Record<string, unknown>` que se muta, o el retorno de un helper | deuda en `OPAQUE_ROOT_TOOLS` |

La tercera categoría existe porque conflarla con la segunda produce justificaciones falsas. Un `const response: Record<string, unknown> = { … }` que después se llena con `response.progress = …` **no ecoa nada**; simplemente no se puede leer qué publica. Anotarlo como "ecoa la API" habría congelado una mentira dentro de una lista que nadie vuelve a mirar. El arreglo es distinto en cada caso: la raíz ecoada se queda tolerante para siempre, la opaca se refactoriza a un literal.

Por eso el escáner usa el AST del compilador y no expresiones regulares. Un escáner textual confunde exactamente las distinciones que la regla necesita: un `...meta` que apunta a un literal local parece un eco, un `Record<string, unknown>` mutado parece enumerable, y un `let data;` con asignación posterior no parece nada.

#### Regla 2 — no se emite ninguna clave que el schema no declare

**Regla.** Toda clave de primer nivel que algún `structured()` emite tiene que estar declarada en el `outputSchema`.

**Por qué, y por qué el fallo es silencioso.** El SDK valida `structuredContent` con zod, y `z.object` **descarta** las claves desconocidas en vez de lanzar. Verificado en `server/mcp.js` (`validateToolOutput`): llama a `safeParseAsync` y **tira `parseResult.data`** — el `structuredContent` original sigue viaje sin filtrar. Así que una clave sin declarar no rompe nada del lado del servidor, pero el JSON Schema publicado dice `additionalProperties: false` mientras el payload lleva más: un cliente que valide rechaza una respuesta que el servidor considera buena.

De ahí una consecuencia de orden: **una tool que subdeclara no puede endurecer su raíz todavía**. Las dos reglas son la misma deuda, así que `KNOWN_UNDERDECLARED` exime de las dos. Primero se declara, después se endurece.

La afirmación solo es posible donde la procedencia es `own`: en una raíz opaca o ecoada las claves emitidas no son enumerables, y un conjunto vacío no significaría que esté todo declarado.

#### Las listas declaradas

Tres, con el mismo régimen que `KNOWN_STRUCTURAL_UNIONS` (`schema-policy.md`): **solo pueden decrecer**. Una entrada que deja de describir una violación falla como obsoleta, y una tool nueva que viole la regla no puede entrar sin que alguien la agregue a mano y la justifique.

- **`ECHOED_ROOT_TOOLS`** — decisión de diseño, no lleva issue. La raíz es un objeto ajeno y la tolerancia es correcta y permanente.
- **`OPAQUE_ROOT_TOOLS`** — deuda, con issue. El arreglo es devolver un literal.
- **`KNOWN_UNDERDECLARED`** — deuda, con issue. Decidir qué claves son contrato y cuáles se caen del payload es criterio de dominio por tool, no una transformación mecánica.

**Claves declaradas en un objeto ecoado.** En objetos ecoados, las claves declaradas son las que el servidor garantiza; el resto viaja bajo `additionalProperties`. Declarar pocas no es subdeclarar: es el contrato mínimo que la tool sostiene aunque la API cambie alrededor. Por eso una declaración como `z.looseObject({ id, uuid, name })` sobre una colección de la API está **completa** —lo que falta no es contrato—, mientras que una `z.looseObject({})` vacía no declara ni siquiera lo que el servidor sí usa, y eso sí es un hueco.

**Orden del spread.** En raíces que ecoan, las claves propias van **después** del spread; lo externo nunca pisa lo canónico. `{ name: componentName, ...doc }` deja que un `name` del CDN gane y desalinee `data.name` de `resource.name` sin error de por medio (#206, `widgets-get-component-props`); `{ ...doc, name: componentName }` es la forma correcta. Vale aunque el tipo del payload no declare la clave: la raíz está en `ECHOED_ROOT_TOOLS` precisamente porque el origen puede agregar claves.


### 2.5 La regla de `message`, precisada (#104)

**La prohibición es profunda.** Ningún objeto de `data`, a ninguna profundidad, declara un `message` que narre la operación. Ni en la raíz ni enlatado: un `message` dentro de `results[]` o de `before` reintroduce por la ventana lo que #104 saca por la puerta, y el `stringify` ciego que verificaba esto antes lo cubría por accidente, sin poder distinguir casos.

**`control.message` es la aparición legítima fuera de `data`.** Es el texto que el cliente renderiza al usuario cuando la llamada no se ejecutó —una cancelación de `confirmDelete`, un rechazo del gate read-only—, y está explícitamente fuera del alcance de #104.

**Dentro de `data` hay una excepción, y es por regla, no por lista:** un objeto puede declarar `message` **si declara también `code`** en el mismo objeto.

El razonamiento importa, porque de él sale la forma de la regla. Lo que #104 combate no es la prosa en sí: es la prosa **como único asidero**. Un `data` cuyo único contenido accionable es una frase obliga al consumidor a ramificar sobre texto que cambia de redacción sin aviso y sin versionado. Un `message` de diagnóstico —"Step 'kyc' referencia una tarea inexistente"— no tiene ese defecto siempre que venga con un identificador estable al lado: el consumidor ramifica sobre el `code` y usa el `message` para que un humano entienda. De ahí que la condición sea *estructural* y no una lista de tools bendecidas: una whitelist habría que mantenerla y crece, mientras que esta condición la cumple o no la cumple cada objeto por su cuenta, y una tool nueva no necesita permiso de nadie.

**De dónde sale el `code`.** De lo que el productor ya sabe. `customers-originations-validate` publica el `checkType` del validador —cinco valores cerrados— bajo el nombre canónico `code`; no hubo que inventar nada. Si en algún caso hiciera falta **diseñar un catálogo de códigos**, eso es una decisión de contrato y no se resuelve dentro de una tool: se decide primero y se implementa después.

Lo verifica `published-schema-contract.test.ts` con `findBareMessages`, que recorre el schema de `data` a cualquier profundidad y falla ante todo objeto que declare `message` sin `code`.

## 3. Proyección en lecturas (#181)

### 3.1 El parámetro

```ts
fields: z.array(z.string()).min(1).optional()
```

Nombres de campo, tal como aparecen en `json.fields` de la lectura individual. Es la forma que pide la medición del 2026-08-17, y es la correcta: `field_id` obliga al agente a una lectura previa del content type solo para traducir nombres a ids.

### 3.2 Por qué la proyección no es un filtro sobre lo que ya se tiene

`GET /content/spaces/{id}/entries` **no devuelve valores de campo en ningún caso** — ni con `includeDetails`, que es el defecto de contrato de #186. La proyección por lo tanto **hidrata del lado del servidor**: resuelve la página con el listado y luego trae el detalle de cada entrada con `processBatch` (concurrencia acotada), proyectando por nombre.

Esto hace explícito el trade que la medición ya había hecho implícito. Hoy el agente hace exactamente estas mismas N lecturas, una por llamada MCP, pagando cada respuesta completa en tokens. Con proyección, las N lecturas **siguen ocurriendo** —del lado del servidor, en HTTP— y lo que colapsa es lo que cruza el borde MCP:

| | HTTP a Modyo | tokens al modelo | confirmaciones del usuario |
|---|---|---|---|
| hoy (N+1 desde el agente) | 314 | 2,05 M medido | 32 |
| con proyección | 314 | ~52 K | 1 |

**El costo en HTTP no cambia; el que cambia en 40–50× es el que estaba rompiendo la tarea.** Y la razón por la que rompía no es el precio: con ~49.000 tokens de resultados por lote, se midió que la detección de incoherencias se degrada — el agente lee las 10 entradas y reporta unas y omite otras. Sin proyección la auditoría no sale cara, sale **incompleta y sin señal de que lo está**. Esa es la familia de defecto que este contrato castiga en general (`complete: false`, `searched`), y acá se ataca en su caso más caro.

La hidratación respeta la paginación: proyecta la página pedida, no el catálogo entero. `state.complete` dice si quedó algo afuera.

### 3.3 Campo inexistente: error, nunca omisión

Un nombre que no existe en el content type **falla la llamada** con un error accionable que lista los nombres disponibles:

```
Campos inexistentes en el tipo "otros-beneficios" (1413): "Descuento".
Disponibles: Discount, Limit date, Day, …
```

La alternativa —devolver las entradas sin esa columna— es precisamente la familia "miente en el éxito": el agente recibe `200 OK`, ve la columna ausente, concluye que ninguna entrada tiene ese valor y audita sobre una premisa falsa. Es el mismo mecanismo de #113 y del `"549895"` de #163. **Un éxito parcial que no se declara es peor que un fallo.**

### 3.4 Assets

Reducidos a `{ name, url }`. El peso del payload no está en los textos: está en los dos objetos de asset completos —avatar del autor, `data_file_size`, miniaturas, fechas, `library_context`— y en la duplicación de los mismos campos en `field_values` y otra vez en `json.fields`.

### 3.5 Interacción con `includeDetails`

**Mutuamente excluyentes**, vía `.refine()` en la raíz del `paramsSchema` (que preserva `.shape`, verificado en zod 4.3.6 — `schema-policy.md` §2). Expresan intenciones opuestas: `includeDetails` agrega metadata administrativa a costa de ~15× los tokens; `fields` recorta a las columnas pedidas. Combinarlos no tiene lectura útil, y elegir un ganador en silencio sería otra vez decidir por el agente sin decírselo.

Las tres formas quedan así:

| llamada | qué devuelve |
|---|---|
| sin `fields` ni `includeDetails` | proyección mínima: `id`, `uuid`, `name`, `slug`, `published` |
| `includeDetails: true` | las entradas crudas del listado (metadata administrativa; **nunca** valores de campo) |
| `fields: [...]` | `id`, `uuid`, `name`, `slug`, `published` + los campos pedidos, por nombre |

### 3.6 El `meta` de la API no se ecoa

Corrección del piloto. `includeDetails` devolvía la respuesta cruda entera, `{entries, meta}`. El `meta` de la API (`total_entries`, `current_page`, `per_page`) es **exactamente** lo que ahora declara `state`, y mandarlo dos veces es la duplicación que el envelope viene a sacar. Se devuelven las entradas; la paginación vive en `state` y en un solo lugar.

Hay además una razón mecánica: el `outputSchema` de la tool declara `data` como `{entries}`, y el `meta` extra lo hacía fallar la validación del SDK. El contrato y el schema tiran para el mismo lado.

### 3.7 Tools multi-acción: un solo objeto, campos opcionales

Corrección que impuso el rollout, no el piloto (que era una lectura de una sola forma).

Una tool `*-manage` devuelve formas distintas según la acción: `get` devuelve el recurso, `list` devuelve la colección con su paginación, `delete` devuelve la identidad de lo borrado, `verify` devuelve `{before, after, changes}`. La forma natural de tipar eso es una unión de objetos — **y es exactamente lo que §2.2 prohíbe**, por las razones de #163 y #157.

**La regla:** el `data` de una tool multi-acción es **un solo `z.looseObject` con todos los campos opcionales**. Una rama estructurada, cero uniones, y el cliente degradado ve un objeto expresable.

El costo a nombrar: el schema deja de decir qué combinación de campos corresponde a cada acción. Se compensa donde el agente sí lo lee — la descripción de la tool y del parámetro `action` —, que es la misma resolución que `schema-policy.md` §3 adopta para las entradas.

#### Mapeo canónico al migrar

| En el `success()` viejo | Dónde va |
|---|---|
| `message: "Template permanently deleted"` | se elimina (#104) |
| `action: "deleted"` / `"updated"` / `"created"` | `state.status` |
| `total`, `page`, `per_page`, el `meta` de la API | `state` (§3.6) |
| `id` / `uuid` / `uid` / `slug` / `name` de lo afectado | `resource` (y sigue en `data` cuando el recurso **es** el payload) |
| `{before, after, changes}` de `verify` | queda en `data`; `state.status` declara el resultado |
| conteos de una operación masiva | `state.affected` |

## 4. Errores

Fuera del alcance de esta fase, con una excepción: los errores siguen saliendo por `errorResponse()` con `isError: true` y **sin** `structuredContent` (el SDK no valida los resultados con `isError`, verificado). La excepción es `platform`: un error también tiene que decir contra qué cuenta ocurrió, que es el criterio de aceptación de #172.

Unificar el contrato de errores es trabajo aparte y no se cuela acá.

## 5. Compatibilidad: qué queda en `content[].text`

**Se mantiene, y contiene el mismo payload que `structuredContent`, serializado compacto.**

Es lo que recomienda la spec MCP para retrocompatibilidad, y las dos alternativas son peores:

- *Un resumen en prosa* — reintroduce por la ventana el `message` que #104 saca por la puerta, y le da al modelo dos representaciones que pueden discrepar.
- *Vaciarlo* — rompe a todo cliente que no lea `structuredContent`, que hoy son todos los que consumen este servidor.

Con criterio de tokens, el cambio de `JSON.stringify(data, null, 2)` a `JSON.stringify(payload)` **ahorra**: la indentación de dos espacios es puro relleno en payloads anidados (§7 lo mide).

El costo que sí hay que nombrar: un cliente que pase **ambos** al modelo paga el payload dos veces. Es el término que #58 tiene que auditar, y la medición del piloto lo reporta por separado (`content[].text` solo, `structuredContent` solo, ambos) para que la decisión sea con números y no con intuición.

## 6. Lo que este cambio NO debe mover

- **Clasificación read-only.** `classifyTool()` decide con `annotations.readOnlyHint` y `getActionOptions()` sobre el `paramsSchema`. `outputSchema` no participa, y `MANAGE_LOCATOR_PARAMS` no se toca. Ninguna tool puede cambiar de clase (`read` / `gated` / `mutation`) por migrar: hay test que lo fija.
- **El golden fixture de instructions.** `tests/instructions/instructions.golden.txt` fija el bloque de instrucciones del handshake, que **no describe la forma de las respuestas** (verificado). No debería necesitar regeneración. Si algún módulo la necesitara, es un acto deliberado con su propio commit y su mención en el reporte — nunca un `--update` reflejo para que pase la suite.
- **La whitelist de `KNOWN_STRUCTURAL_UNIONS`.** No crece.
- **`paramsSchema`**, salvo el `fields` que exige #181.

## 7. Medición

Cada módulo migrado reporta, con sonda JSON-RPC contra el build (no estimaciones), tokens antes/después con el desglose `content[].text` / `structuredContent` / ambos, el costo fijo del envelope, y —donde haya proyección— `fields` contra la vía actual.

**Contador.** La medición de campo de #181 contó 41.593 tokens para 120.689 caracteres con el tokenizador del modelo que corre el agente: **2,902 chars/token**. Se usa esa constante, no la aproximación `chars/4`, que sobre JSON denso subestima.

**Resultados del piloto** (`content-entries-list`, fixtures modeladas sobre el caso de #181: 314 entradas, 31 campos, 2 assets):

| llamada | antes (`main`) | después | Δ |
|---|---:|---:|---:|
| mínima, `per_page=10` | 804 | 672 | **−16%** |
| mínima, `per_page=100` | 7.883 | 6.201 | **−21%** |
| `includeDetails`, `per_page=10` | 4.724 | 3.248 | **−31%** |

`content[].text` **baja** pese a sumar el envelope: la serialización compacta paga de sobra los ~20 tokens de `platform` más los de `state`, y en `includeDetails` se suma dejar de ecoar el `meta` (§3.6).

**Proyección** (10 entradas, 3 campos):

| vía | tokens | HTTP |
|---|---:|---:|
| actual: `list` + 10× `content-entries-get` | 53.094 | 11 |
| `fields: ["Discount","Limit date","Day"]` | 1.372 | 12 |

**38,7×** menos. A `per_page=100`: 13.261 tokens para 100 entradas → **133 tokens por fila**, contra los ~120 que anticipaba #181. Extrapolado a las 314 entradas del caso: **~42.000 tokens** contra los 2,05 M medidos en campo, dentro del rango de 40–50× que el issue proyectaba.

El costo en HTTP no cambia (12 llamadas contra 11): son las mismas lecturas que el agente ya hacía, movidas al lado del servidor.

**Lo que sube:** `tools/list`, +393 tokens en esta tool, de los cuales 206 son el envelope. Ver §2.1 — es el término que #58 tiene que auditar y la decisión que el rollout le pone al operador.

## 8. Orden de rollout y criterio de done

Piloto: **content/entries** (`content-entries-list` con proyección) — es donde están las mediciones de #181 y el caso de #186.

Después, criterio de orden **superficie ascendente**: los bordes ásperos del contrato aparecen primero en los módulos chicos y uniformes, y el más grande se migra cuando ya está asentado.

1. `content` (resto: types, spaces, categories, assets, jobs)
2. `core` (team, groups, roles, settings)
3. `customers` (realms, users, forms, datasets, segments, originations)
4. `channels` (sites, pages, templates, widgets, menus, releases, locks, variables) — el más grande
5. `widgets` (catálogo local)

**Done por módulo:** todas sus tools emiten `structuredContent` y declaran `outputSchema`; ningún `message` en payloads; suite completa en verde; medición agregada al reporte.

**Cierre de las issues** (en el PR final, no antes):

- **#104** — cuando ningún payload del catálogo lleve `message` y `success()` no tenga consumidores.
- **#167** — cuando toda tool de escritura declare `state` de post-condición y `data` traiga los valores persistidos releídos.
- **#172** — cuando `platform` salga en toda respuesta y en todo error, con default derivado del host.
- **#181** — cuando las lecturas de gran payload acepten `fields`, las de búsqueda declaren `searched`, y las resoluciones heurísticas hagan eco en `resource`.

## Relacionado

- `schema-policy.md` — la regla de schemas del lado de la entrada; §2.1 de este documento la endurece para salidas.
- `tool-patterns.md` — el patrón concreto que las tools nuevas copian.
- #106 — split `read`/`write`/`destroy`, mismo scope de archivos; este rediseño va antes o junto, no después.
- #58 — epic de footprint: el marco donde se auditan los costos de §5 y §1.1.
- #41 — "LLM Compatibility First".
- #186 — `includeDetails` prometía `field_values`; §3.5 fija lo que cada forma devuelve.
- #113 — truncado silencioso; `state.complete` es su contrapartida general.
