# Política del schema publicado

> Doctrina de contribución para los `paramsSchema` de las tools.
> Ejecutable: `tests/forcing-functions/published-schema-contract.test.ts` la verifica en cada PR y push (#164).

El **schema publicado** es el JSON Schema que sale por `tools/list`, no el zod que se escribe. La diferencia importa: `ToolBase.extractRawSchema()` lee `.shape` y el SDK serializa desde ahí, así que lo que ve un cliente puede no ser lo que sugiere el código (precedente: #77 / PR #108). Toda regla de este documento se enuncia y se verifica sobre el schema publicado.

## 1. Sin `oneOf`/`anyOf` con más de una rama estructurada

**Regla.** Un `paramsSchema` publicado no debe contener `anyOf` ni `oneOf` con más de una **rama estructurada** —de tipo objeto o de tipo array— a ninguna profundidad (`items`, `properties` anidadas, `additionalProperties`, `allOf`, `$defs`).

Cuenta como rama estructurada la que declara `type: "object"` o `type: "array"`, o la que no declara `type` pero tiene `properties` o `items`.

**Permitido.**

- Las uniones de **escalares**: `identifier: z.union([z.string(), z.number()])`, el patrón de identificador inteligente documentado en `CLAUDE.md`, no se degrada y se queda.
- **Una** rama estructurada más una o más escalares (`anyOf: [{type: "array"}, {type: "string"}]`). El cliente degradado se queda con la única estructurada y puede expresarla: lo que pierde es un atajo, no una capacidad.
- La forma `anyOf: [{objeto}, {type: "null"}]` que emiten los opcionales y los `nullable`: es una sola rama estructurada.

**Por qué.** No es que el schema esté mal. Un `anyOf` de objetos es correcto, estándar y autodocumentado. El problema es que **hay clientes MCP que lo degradan**, y ya hay dos incidentes independientes de la misma causa:

- **#157 — la function-calling de Gemini no soporta `oneOf`/`anyOf`.** Con las tools de `customers-origination-*` en el set, la request falla con `400 INVALID_ARGUMENT` **antes de ejecutar nada**: al aplanar el schema se pierde el `items` de un array anidado. El MCP completo queda inusable desde Gemini; el workaround fue desactivar esas tools.
- **#163 — Langflow 1.11 convierte el JSON Schema a Pydantic con un conversor con pérdida.** De un `anyOf` **se queda solo con la primera rama** y no convierte `const` a `Literal`. La unión de 17 variantes de `field_values` colapsa a la primera (`StringField`), así que los campos `integer`, `decimal` y `asset` quedan inescribibles: no existe ningún payload que pase a la vez la validación del cliente y la del servidor.

**El modo de falla que hace esto P0: la degradación puede no dar error.** En #163, verificado contra plataforma real, `{"type":"string","value":"549895"}` para un campo `asset` pasa el cliente, pasa zod, pasa la API y responde `"updated"` — y persiste el string literal `"549895"` en el campo. El sitio hace `entry.fields.Image.url` → nil → tarjeta rota. Ninguna de las tres capas emitió un error. Corrupción silenciosa de datos: eso es lo que esta regla previene, no una incompatibilidad cosmética.

**Por qué "estructurada" y no solo "objeto".** El mecanismo de degradación no distingue el tipo de las ramas: el cliente toma la primera y las demás quedan inexpresables. Da igual si son objetos entre sí o un array contra un objeto. El caso testigo es `content-types-manage` / `properties.fields`, publicado como `anyOf: [{type: "array"}, {type: "object", properties: {add, remove}}]`: un cliente que se queda con la primera rama solo ve el array de reemplazo, y **la forma `add`/`remove` queda inalcanzable** — el agente no puede agregar un campo sin reescribir el schema completo del content type. El mismo idiom aparece en `core-groups-manage` (`sites`, `realms`), donde la propia tool ya demuestra la alternativa correcta: para usuarios usa parámetros separados (`admin_users` / `add_users` / `remove_users`), que no son unión y no tienen el problema. Registrado en #194.

## 2. Sin `.transform()` en la raíz de un `paramsSchema`

**Regla.** El `paramsSchema` debe ser un `z.object()` en la raíz, con `.shape` accesible. Un `.transform()` (o `.pipe()`) en la raíz está prohibido.

**Por qué.** `.transform()` devuelve un `ZodPipe`, que no tiene `.shape`. Dos mecanismos leen `.shape` directamente:

1. **`ToolBase.extractRawSchema()`** (`src/shared/ToolBase.ts`) — registra los parámetros desde `.shape`, y cae a `{}` si no existe: la tool se publicaría **sin parámetros**.
2. **El gate read-only** (`src/shared/readOnly.ts`, `MODYO_READ_ONLY=true`) — `getActionOptions()` lee `.shape.action` para obtener las opciones del enum, y `classifyTool()` clasifica la tool en `read` / `gated` / `mutation` con eso más `annotations.readOnlyHint`. Sin `.shape`, `getActionOptions()` devuelve `null` y la tool **se reclasifica**: `ModyoMcpServer` la trata como mutación pura y la saca del catálogo en modo read-only, o la deja sin gate por acción. Es decir: un `.transform()` en la raíz mueve silenciosamente la superficie de seguridad.

**Qué usar en su lugar.** `.refine()` / `.superRefine()` para validación cruzada (preservan `.shape`, ver la sección *Validation* de `CLAUDE.md`), y transformaciones **por campo** (`z.coerce.*`, `z.preprocess` dentro de una propiedad) cuando hace falta normalizar entrada.

## 3. Dirección de fix prohibida para #163: `discriminatedUnion`

`z.discriminatedUnion("type", …)` **no es una solución aceptable** para #163. Mejora el mensaje de error (reporta una rama en vez de 17, ~200 líneas), pero **sigue emitiendo `oneOf`**, que es exactamente lo que rompe a Gemini en #157. Los dos incidentes tiran en direcciones opuestas y esta es la resolución: la política prohíbe la **forma del schema resultante**, no una técnica de implementación.

Concretamente: `discriminatedUnion` es la única dirección descartada de antemano. **La forma final del fix se decide en #163**, y quedan abiertas, entre otras:

- Un schema **permisivo** en el borde MCP (por ejemplo `value: z.unknown()`, o un objeto con campos opcionales y `type: z.enum(...)`), con coerción y dispatch de tipos del lado del servidor. En `content-entries-*` el schema del content type ya se descarga en cada upsert (`resolveFieldIds`), así que la validación server-side no cuesta llamadas extra y además permite **corregir o rechazar** un `type` que no coincide con el tipo real del campo — que es lo que elimina la corrupción silenciosa.
- Colapsar la unión a un objeto único con `.superRefine()` para la validación fina (la dirección que propone #157).
- Reemplazar por un enum de escalares cuando la unión de objetos era un enum disfrazado (caso de `validations.asset_types`, #193).
- Separar las dos formas en parámetros distintos cuando la unión expresaba "reemplazo o delta" (caso array-vs-objeto, #194).

El costo a nombrar: una unión de 17 ramas es autodocumentada y un schema permisivo le quita al modelo la guía de qué mandar. Se compensa en la **descripción del parámetro**, que es donde el agente sí la lee.

## 4. La whitelist existe para llegar a cero

El test mantiene `KNOWN_STRUCTURAL_UNIONS`, la lista de violaciones que hoy existen en el catálogo. Reglas de uso:

- **Agregar una entrada requiere justificación e issue vinculada.** Sin issue, el test falla. Es el último recurso, no la salida por defecto: la alternativa correcta es rediseñar el parámetro.
- **Las entradas se identifican por nombre de tool + ruta dentro del schema publicado** (`properties.field_values.items`), nunca por número de línea. La ruta se interpreta como prefijo y cubre el subárbol.
- **El test falla en las dos direcciones**: si aparece una violación nueva fuera de la lista, y también si una entrada listada **ya no viola** — en ese caso el mensaje pide eliminarla. Así la lista no se desactualiza cuando #163, #157, #193 y #194 se resuelvan.
- La lista **no debe crecer**. Cada PR que la agranda es una decisión revisable en el diff; cada PR que la achica cierra deuda.

## Verificar

```bash
npx vitest --run tests/forcing-functions/published-schema-contract.test.ts
```

Entra en CI sin tocar workflows: `.github/workflows/lint-and-test.yml` corre `npm run test:coverage`, la misma configuración de Vitest que `npm run test:run`. El test levanta un `McpServer` en proceso, registra las tools con el mismo `registerTool` que usa `McpServerBase`, y hace un `tools/list` real sobre `InMemoryTransport`: los schemas que inspecciona son los que ve un cliente, sin reimplementar la serialización.

## Relacionado

- #164 — este contrato y su test. · #163 — `field_values` desde Langflow. · #157 — originations desde Gemini. · #193 — `validations.asset_types`. · #194 — uniones array-vs-objeto (forma simple o granular).
- #41 — "LLM Compatibility First": diseñar para el cliente y el modelo menos capaces que deberían funcionar.
- #185 — epic del feedback de la evaluación de Langflow, con el razonamiento de las dependencias entre #163 y #157.
