Errores gobernados — approach
Fecha: 2026-08-12. Cómo llevar el manejo de errores del SQL Editor al nivel de
Databricks/Snowflake, sobre el sustrato que ya tenemos.Regla de la casa: cada hecho lleva el comando que lo demuestra. Lo que no lo lleva
va marcado como pendiente u opinión.Se ordena detrás de
parser-al-motor-spec.md— el
paradigma de verbos ricos gobernados —, y el §7 explica por qué ese orden no es
arbitrario. Mandasustrato-06.mddonde discrepen.
1 · La pregunta
«Los errores que salen en el output del editor, en español — ¿cómo los gestionamos?
Databricks y Snowflake tienen un manejo muy maduro. ¿Qué tecnologías usan? ¿Es
nuestro sustrato suficientemente maduro para algo de ese calibre?»
Sí, y la parte difícil ya está hecha. Lo que falta no es arquitectura: es transporte y registro. Ahora mismo estamos recibiendo errores estructurados del motor y aplanándolos a texto antes de que nadie pueda usarlos.
2 · Lo medido — nuestro estado, hoy
✅ Lo que ya está bien, y no es poco
Hay una taxonomía tipada, no cadenas sueltas
(contract.ts:487):
QueryRejection = 'unresolved-table' | 'not-readable' | 'no-tables' | 'cross-workspace'
| 'no-workspace' | 'write-not-enabled' | 'verb-not-allowed' | 'plan-failed'
Y QueryRejectedError lleva reason y detail: Record<string, unknown>, con el
comentario «datos para que la superficie construya un mensaje útil». Eso ya es,
conceptualmente, un mapa de parámetros de mensaje. La intención estaba bien desde el
principio; lo que falta es que sobreviva al viaje.
⭐ Y not-readable —«el principal lo VE (BROWSE) pero no puede LEERLO»— demuestra que
la distinción fina ya se pensó. Volveremos a ella en §7, porque es justo donde OpenFGA
deja de ser un lujo.
⛔ Los tres agujeros
| Qué | Dónde | |
|---|---|---|
| 1 | El error del MOTOR se aplana a texto antes de salir del puente | _resumir_error(e) -> str en services/spark-bridge/app.py:60 |
| 2 | Dos nombres para el mismo campo en la superficie HTTP: un camino manda rejection, otro rejected | app/api/sql-editor/execute/route.ts |
| 3 | Los errores de motor pierden el código entero: sólo viaja err?.message | ídem |
Medido en vivo durante el canary de P·5:
AnalysisException: [TABLE_OR_VIEW_NOT_FOUND] The table or view `p4_fx_a` cannot be found…
TABLE_OR_VIEW_NOT_FOUND es un identificador estable de Spark. Nos está llegando —
dentro de una frase, como texto, para que alguien lo lea con los ojos.
3 · Lo que hacen los líderes
Apache Spark 4 tiene un framework de condiciones de error: un error-conditions.json
central, cada condición con SQLSTATE ANSI de 5 caracteres, mensajes
parametrizados y subcondiciones (INCOMPLETE_TYPE_DEFINITION.ARRAY). La API
expone getCondition(), getSqlState() y getMessageParameters() desde 4.0.0.
⭐ Nosotros corremos spark 4.1.2-stackable26.7.0. No hay que construirlo: hay que
dejar de tirarlo.
Databricks construye encima: reserva la clase SQLSTATE KD para las suyas y XX
para errores internos que merecen bug report. Y deja escrita la regla de diseño:
Para manejar un error programáticamente, usa la condición, el SQLSTATE y los
parámetros — no el texto. El texto puede cambiar o localizarse sin aviso.
4 · ⭐⭐⭐ La tesis: el texto no puede ser la IDENTIDAD
El problema no es que nuestros mensajes estén en español.
El problema es que el texto en español es hoy la ÚNICA identidad del error.
Cuando la identidad es condición + SQLSTATE + parámetros, el texto queda libre:
puede estar en español, traducirse, o reescribirse mañana sin romper a nadie. Es al
revés de como suena — la estructura es lo que PERMITE localizar.
Y desbloquea lo que hoy es imposible: la UI puede subrayar la tabla que no existe en
el editor, porque sabe cuál es (relationName viene en los parámetros), en vez de
tener que sacarla de una frase con una expresión regular.
5 · El plan — E·0 a E·5
Cada fase con su gate. Ninguna se declara hecha sin él.
| Qué | Gate | |
|---|---|---|
| E·0 | ⭐ El puente deja de aplanar. En su except BaseException, devolver {condition, sqlstate, messageParameters, message} en vez de una cadena. Es nuestro código y son ~10 líneas | una sonda que provoque 5 errores distintos y compruebe que los 5 traen condición y SQLSTATE |
| E·1 | El contrato de transporte, UNO. Un solo campo en la respuesta HTTP —no rejection y rejected— con la forma {condition, sqlstate, params, message} para los dos orígenes: la puerta y el motor | un test de la ruta por cada origen · y un trinquete que falle si aparece un segundo nombre |
| E·2 | El catálogo de condiciones de Carbon. Nuestros 8 motivos pasan a condiciones con nombre estable y SQLSTATE propio en la clase KD (siguiendo a Databricks), documentadas en un fichero que se GENERA, como el contrato de Carbon SQL | check:error-conditions — el doc casa con el código |
| E·3 | Parámetros de verdad. detail deja de ser libre y pasa a estar declarado por condición: unresolved-table lleva {relationName}, cross-workspace lleva {workspaces} | tipos por condición · el test falla si una condición se lanza sin sus parámetros |
| E·4 | La UI reacciona por condición, no por texto: subrayar el identificador que falló, ofrecer la acción que corresponde, y el texto pasa a ser una plantilla | e2e por condición en el editor |
| E·5 | Localización. Sólo cuando E·2-E·4 estén: traducir antes es traducir la identidad | el mismo e2e en dos idiomas |
⚠️ E·0 antes que todo, y no por facilidad: mientras el puente aplane, cualquier cosa
que construyamos encima estará adivinando sobre una cadena. Es el mismo error que
sql-parse.ts cometía con el SQL, y del que hemos tardado una semana en salir.
6 · Lo que este plan NO es
- No es traducir mensajes. Es darles identidad para que traducirlos sea seguro.
- No es un catálogo de mensajes bonitos. Nuestros mensajes ya explican por qué y qué hacer mejor que la media. Lo que no tienen es con qué reaccionar.
- No es un envoltorio genérico de errores. Los errores de infraestructura
(
workspace_lookup_failed, el 503 del puente) son otra familia: ahí el trabajo es reintentar y propagar el motivo, no clasificar semántica de SQL.
7 · ⭐⭐ OpenFGA — qué papel tiene, y por qué los errores lo NECESITAN
7·1 · Dónde estamos, medido
La retícula ya existe y aplica: CATALOG_OPS
(catalog-authz.ts:58) mapea operación →
privilegio → alcance, y decidePrivilege resuelve por cadena, o sea con herencia.
La tabla de grants tiene la forma de securables de Unity Catalog:
principal_grants ......... 368 filas
por tipo de principal .... service 368 ⛔ CERO de usuario
columnas ................. workspace_id · principal_kind · principal_id ·
principal_role · catalog_role · privilege ·
securable_kind · securable_id
⇒ Lo que falta no es un motor de políticas: son los grants de usuario. Eso es P·2, y es independiente de OpenFGA.
7·2 · Para qué es NECESARIO OpenFGA, entonces
Tres cosas que una tabla de grants no expresa bien, y que llegan sí o sí:
- Relaciones, no filas. «El equipo X hereda de la carpeta Y», «este usuario es dueño de este esquema, luego puede todo lo de dentro», «me compartieron esta tabla». Con grants planos, cada una de esas frases se convierte en N filas que hay que mantener sincronizadas. ReBAC las expresa como lo que son: relaciones.
- El grafo. El norte es que la ontología herede la gobernanza del catálogo por
relación (
catalogo-origen-del-grafo.md). Eso es literalmente un problema de ReBAC — y gobernar el traversing no se puede con una tabla de privilegios por objeto. - Ya autoriza al catálogo. OpenFGA está desplegado y es quien autoriza a Lakekeeper. O sea: ya es el aplicador en el punto de acceso. Tenerlo también del lado del producto elimina dos modelos de autorización para el mismo warehouse.
7·3 · ⭐⭐⭐ Y el vínculo con los errores, que es el hallazgo de este documento
La forma de un error es una decisión de autorización.
Cuando un usuario pide una tabla que no puede ver, hay dos respuestas posibles y no son equivalentes:
«esa tabla no existe» → no filtra nada, pero MIENTE
«no tienes permiso» → es cierto, pero CONFIRMA QUE EXISTE
La segunda es una fuga: revela la existencia de un objeto y a menudo su nombre. Los catálogos maduros eligen a propósito y por caso, y para elegir hace falta saber qué le está permitido SABER a ese principal — que es una pregunta de autorización, no de mensajería.
⭐ Y nosotros ya tenemos media respuesta escrita: not-readable distingue «lo VE
pero no puede LEERLO» de «no existe». Esa distinción sólo tiene sentido con un
modelo de autorización que separe BROWSE de SELECT — exactamente lo que ReBAC modela y
una tabla de grants plana aproxima.
⇒ E·2 (el catálogo de condiciones) no se puede cerrar bien sin decidir esto. Se puede empezar sin OpenFGA —las condiciones de sintaxis, dialecto y planificación no dependen de él— pero las condiciones de acceso sí, y son las que más se ven.
7·4 · El orden que se deduce
verbos ricos gobernados (P·4 · P·5) ← EN CURSO. El primero ya está en producción
└─ E·0 · E·1 el transporte del error no depende de nada
└─ P·2 los grants de USUARIO desbloquea la retícula para humanos
└─ E·2 · E·3 las condiciones de acceso necesitan saber qué puede SABER cada uno
└─ OpenFGA cuando las relaciones superen a las filas
└─ E·4 · E·5 la UI y la localización
OpenFGA no es el siguiente paso, y tampoco es opcional. Es el paso en el que relaciones sustituyen a filas — y ese momento lo marca el producto (compartición, equipos, el grafo), no una preferencia técnica.
8 · Los riesgos, sin maquillar
- Un catálogo de condiciones es un CONTRATO. En cuanto la UI reaccione a
unresolved-table, renombrarlo rompe clientes. Por eso E·2 exige que se genere y se trinquete, como el contrato de Carbon SQL. - Las condiciones del motor no son nuestras.
TABLE_OR_VIEW_NOT_FOUNDlo define Spark y puede cambiar con la versión. Hay que decidir si se reexportan tal cual (atándonos a su versionado, que es lo que hace Databricks) o se traducen a las nuestras en la frontera. La primera es más honesta y más barata; la segunda aísla. - Los parámetros pueden filtrar.
messageParameterslleva nombres de tablas y columnas. Lo que se devuelve al cliente pasa por la misma decisión de §7·3, y por defecto no se reenvían sin mirar. - Es fácil confundir familias. Un 503 del puente y un
TABLE_OR_VIEW_NOT_FOUNDno se arreglan igual ni se cuentan igual. Mezclarlos en un envoltorio genérico daría un número alarmante y falso — §12·39.