Published

⭐⭐ INGESTA GOBERNADA · APPROACH — de tubo a escritura

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

⭐⭐ INGESTA GOBERNADA · APPROACH — de tubo a escritura

Suelo obligatorio: ingesta-gobernada-fundamentos.md.
Los pilares ①-⑤ y el mapa de infraestructura se dan por leídos; aquí no se repiten.

Qué entrega: que ingerir desde Postgres sea una escritura más — misma puerta,
misma política, mismo motor, mismo catálogo — disparable desde el SQL Editor, desde una
celda de notebook o por un planificador.

Qué NO entrega, y se dice ahora: el incremental/CDC. Es terreno reconocido como
pantanoso, y diseñarlo antes de tener una copia funcionando es adivinar. Va en un
approach aparte, y §7 dice qué hay que preservar para no cerrarse la puerta.


1 · El principio que ordena todas las fases

Nada nuevo debajo de la puerta.
Cada capacidad entra por runQuery, se decide con decidePrivilege y se ejecuta con el
motor que ya hay. Si una fase necesita un camino propio, la fase está mal planteada.

De ahí salen tres consecuencias que valen como criterio de rechazo:

  • Un verbo nuevo no trae un ejecutor nuevo. El carril de gobernanza (G·6) ya demostró que interceptar antes del motor es barato.
  • Un secreto nuevo no trae un almacén nuevo. El vending de la cara ya existe.
  • Un disparador nuevo no trae un motor nuevo. Manual y programado ejecutan lo mismo.

2 · Las fases

🔍 C·0 · El CENSO DEL TUBO — antes de suprimir, saber qué se pierde

El worker actual sabe cosas que no están escritas en ningún sitio: estrategias por tabla (sequence, timestamp), watermark y reanudación por checkpoint, fan-out por rangos de PK, mapeo de tipos, descubrimiento de FKs, streaming por lotes con techo de RAM.

Suprimir sin censar es perder capacidades y descubrirlo en producción.

Gate c0 · un documento generado (no escrito a mano) que liste, por capacidad,
si el camino nuevo la cubre, la pospone o la abandona a propósito. Ninguna entrada
puede decir «pendiente de mirar».


🧱 C·1 · La CONEXIÓN, un securable

Entidad en Index con dueño, tenencia y privilegios propios (USE CONNECTION, CREATE CONNECTION, MANAGE CONNECTION), integrada en la cadena que ya recorre decidePrivilege. El secreto por referencia al almacén de credenciales que ya existe; nunca un valor literal en la fila.

⚠️ El vocabulario se queda corto a propósito — la misma regla que MAPEO en grant-sql.ts: cada entrada nueva es una decisión sobre qué puede hacer alguien.

Gate c1 · decidePrivilege concede y deniega sobre una conexión, con control
negativo
: un rol sin USE CONNECTION no la ve, y un principal de otro inquilino
tampoco. Sin el negativo el gate no distingue nada.

🏁 C·1 · CERRADO (2026-08-16)

connection-securable.test.ts (16 en frío) · c1-gate-conexion-securable.ts (VERDE en vivo) · 458 tests en lib/governance · tsc limpio · migración 20261290 aplicada.

El modelo: la conexión es un securable LATERAL — cuelga del workspace y no de la cadena de datos. Meterla en SECURABLE_HIERARCHY habría dicho que está debajo de dataset, que es falso; su cadena es [workspace, connection] y buscaGrant empareja por posición, no por profundidad, así que hereda del inquilino sin tocar el orden de contención. Y no aparece en TRAVESIA_DE: una hoja no se atraviesa.

Vocabulario de tres, y WORKSPACE_MANAGE_CONTENT implica CONNECTION_USE pero no CONNECTION_CREATE ni CONNECTION_MANAGE_ACCESS: traer un sistema externo al inquilino y repartir su credencial no caen de administrar contenido.

⭐⭐ Lo que enseñó el gate: un securable nuevo toca TRES sitios en la base

Y ninguno avisa del otro. El gate los encontró de uno en uno, una corrida por eslabón:

quécómo falla si se olvida
CHECK del nombre del privilegio23514 — TypeScript, assertGrantable y la API dan 201, Postgres mata
El CASE del trigger de tenenciacase not found — y, peor, el securable queda fuera de la comprobación de cruce entre inquilinos
CHECK de securable_kindrechazo del CHECK, ya con todo lo demás bien

⇒ El CASE del trigger ahora lleva ELSE explícito: el próximo securable lateral fallará con un motivo legible en vez de con case not found.

⭐ La asimetría fue lo que señaló al culpable: CONNECTION_CREATE sobre workspace pasaba y CONNECTION_USE sobre connection no. Un gate que sólo dijera «falla» no habría distinguido el trigger del CHECK.


📝 C·2 · La GRAMÁTICA

CREATE CONNECTION · DROP CONNECTION · SHOW CONNECTIONS, interceptadas en la puerta antes del motor, exactamente como el carril de gobernanza.

⚠️ El orden del carril en la ruta es un requisito, no un detalle (lección de G·6·5): la gramática de catálogo reclama las tres formas de SHOW. Va con test que rompe si alguien reordena los if.

⭐ Y el vocabulario se deriva, no se copia: el resaltado del editor y el autocompletado salen de la misma constante que el parser — el séptimo sitio de la lista de verbos ya enseñó lo que cuesta olvidarse de uno.

Gate c2 · tests del parser con las formas admitidas y las rechazadas con su
motivo
; el test de orden del carril; y el trinquete del léxico
(grant-sql-resaltado.test.ts como plantilla) verde para los verbos nuevos.

🏁 C·2 · CERRADO (2026-08-16)

connection-sql.test.ts (22) · 479 tests en lib/governance · tsc limpio.

CREATE CONNECTION pg_ventas TYPE POSTGRESQL
  OPTIONS (host 'db.example', port '5432', database 'ventas', credential 'ventas-ro')
DROP CONNECTION [IF EXISTS] pg_ventas
SHOW CONNECTIONS

⛔⛔ La regla que define la gramática: el secreto NO cabe en la sentencia. password, secret, token, pwd, api_keyse rechazan con un motivo que dice qué hacer — no se ignoran. Un parser que ignora una opción desconocida acepta la sentencia y deja al usuario creyendo que su contraseña se guardó. credential es una referencia a user_credentials, que ya cifra. Y el léxico no contiene ningún nombre de secreto: sugerirlo sería enseñar la forma que el parser rechaza.

⭐⭐ La integración limpia: sql-lexico.ts

El editor derivaba su léxico de una gramática (grant-sql). Con la segunda, la salida fácil era importar de dos sitios — y de tres con la siguiente: reintroducir el problema del séptimo sitio con más pasos.

lib/governance/sql-lexico.ts es el único sitio que el editor conoce. Añadir una gramática es añadir una línea allí; el resaltado y el autocompletado la heredan sin tocarse. Cada entrada lleva su reconocedor junto a su léxico, y un test comprueba que toda gramática registrada tiene quien la conteste — si alguien aportara palabras al resaltado sin registrar el reconocedor, el editor colorearía verbos que la puerta no entiende.

Y el ejecutor, que no se dejó para después

Una gramática que se reconoce y no hace nada es SQL que aparenta funcionar — la misma regla que ya rige para los privilegios. connection-sql-executor.ts autoriza con decidePrivilege sobre la cadena real (no con criterio propio), lista sólo lo que se puede usar (un listado que enseña lo que no se puede tocar filtra la topología del cliente), y hace soft-delete: una conexión retirada sigue explicando de dónde vino lo que ya se copió. ⛔ Y no lee encrypted_data — hay un test que lo comprueba.


🔐 C·3 · El VENDING de la credencial

Extender el mecanismo de app/api/storage/credential a conexiones: la cara resuelve el secreto por petición, con TTL corto y scope derivado de conexión × principal.

Invariantes, y son de rechazo:

  • el secreto no aparece en el texto del SQL, ni en el plan, ni en el ledger, ni en un log, ni en la config del clúster;
  • la credencial caduca sola; si el trabajo dura más, se renueva, no se alarga;
  • quien no puede usar la conexión no obtiene credencial, y el motivo lo da el plano de permisos, no el driver.

Gate c3 · un test que busca el secreto en plan, ledger, logs y respuesta, y
falla si aparece. Más control negativo: principal sin USE CONNECTION → sin credencial.

🏁 C·3 · CERRADO (2026-08-16)

connection-credential.test.ts (10 en frío) · c3-gate-vending.ts (VERDE en vivo) · 489 tests · tsc limpio.

⚠️ Lo que NO se pudo hacer, dicho sin adornos

El vending de storage acuña credenciales derivadas y de corta vida porque S3/R2 tienen STS. PostgreSQL no: no hay forma estándar de emitir una contraseña temporal para un Postgres cualquiera. Aquí no se acuña nada — se resuelve el secreto que ya existe.

Fingir lo contrario habría sido el peor resultado: un TTL decorativo hace creer que hay una protección que no está. Lo que sí es real:

  • la autorización se comprueba en cada petición, no una vez al configurar;
  • el material entregado caduca (revelar() lanza pasado el TTL), así que un trabajo largo tiene que re-autorizar — y si le revocaron CONNECTION_USE a mitad, falla;
  • queda registrado quién la pidió, permitido y denegado.

⭐⭐ El invariante no se pide: se hace imposible

SecretoDeConexion no se serializa: JSON.stringify"[secreto de conexión]", `${s}`[secreto de conexión], console.log/util.inspect → igual. El campo es privado (#valor), así que ni el spread ni Object.keys lo alcanzan. Sólo .revelar() lo entrega — y eso se encuentra con un grep.

⇒ El gate persigue un valor concreto por todas las vías por las que un secreto se escapa de verdad: respuesta HTTP, interpolación, logs (capturando console.log), spread y el ledger de accesos. Un invariante que depende de la disciplina de quien programa no es un invariante.

⭐ El CUARTO eslabón, y por qué casi no aparece

C·1 encontró tres sitios que hay que tocar al añadir un securable. Hay un cuarto: el CHECK de access_eventsy está particionado por mes. Dos matices que costaron:

  • las constraints de las particiones son heredadas: tocarlas una a una da «cannot drop inherited constraint». Se opera sólo sobre la tabla padre y Postgres propaga;
  • ⚠️ sólo se ve con ENABLE_ACCESS_EVENTS=true. Con el interruptor apagado el evento ni se intenta, el gate cuenta 0 y no distingue «apagado» de «roto» — que es exactamente cómo un gate aprueba sin significar nada. El gate ahora lo distingue y dice cuál es el comando para medirlo de verdad.

Añadir un securable toca CUATRO sitios en la base, y el cuarto vive detrás de un interruptor apagado por defecto.


👁️ C·4 · SHOW SCHEMAS/TABLES IN <conexión>

La introspección la contesta la puerta, no el catálogo — la cara habla Iceberg REST y no tiene el concepto de tabla JDBC (fundamentos §3). Es metadata, no datos: mirar antes de copiar.

Gate c4 · devuelve el censo real de un Postgres de prueba y deniega sin
USE CONNECTION. Que devuelva algo no basta: tiene que negarse cuando toca.

🏁 C·4 · CERRADO (2026-08-16)

c4-gate-introspeccion.ts VERDE contra un Postgres real (Docker): lista los esquemas y tablas de verdad, deniega sin CONNECTION_USE con motivo del plano de permisos, y no filtra la contraseña del origen.

⭐⭐ La decisión que ordena esta fase: la desambiguación es de EXISTENCIA

SHOW SCHEMAS IN x y SHOW TABLES IN x.y ya son de la gramática de catálogo (M3), donde x es un catálogo o schema del warehouse. Interceptarlas por sintaxis le habría quitado SHOW TABLES IN main.default al warehouse, y el fallo habría aparecido lejos —en una pantalla que dejó de listar tablas—, no aquí.

⇒ Se extrae el nombre y decide el ejecutor mirando si existe una conexión que se llame así. Si no la hay devuelve noEsConexion y el flujo sigue hasta el catálogo. Por eso este carril es el único que no puede return en el caso negativo.

El gate fija las dos formas que el catálogo conserva, y fallan distinto:

SHOW TABLES IN mainun solo segmento: ni siquiera tiene la forma (null)
SHOW TABLES IN main.default tiene la forma — sólo la existencia lo salva

El segundo es el que prueba la desambiguación de verdad; el primero solo habría dado una sensación falsa de cobertura.

Dos detalles que costaron una corrida

  • SSL: se conecta con sslmode=prefer (el default de psql) — intenta cifrado y cae a claro sólo si el servidor no lo soporta. Forzar SSL rompía contra cualquier Postgres sin TLS; forzar claro sería peor, mandaría la credencial en abierto contra uno que sí lo admite.
  • El censo excluye los esquemas del sistema (pg_*, information_schema): lo que interesa es lo que puso el cliente, no las tripas de Postgres.

El usuario escribe una sentencia. La puerta la descompone en createTable (sano desde el 08-16) + INSERT por lotes, porque el CTAS no existe en Gravitino (apache/gravitino#10750).

⛔⛔ El modo de fallo está documentado en esa misma issue y hay que diseñarlo desde el principio: si el CREATE va bien y el INSERT falla, la tabla queda registrada apuntando a metadata que nunca se escribió — y después ni siquiera se puede borrar. Medido aquí el 08-16: una tabla de prueba quedó exactamente así.

⇒ La descomposición obliga a compensación. La cara ya tiene el precedente (compensateMaterialise y el bloque G·1 de createTable), y la regla también: la duda no borra — sólo se compensa cuando el catálogo confirma que no hay nada.

Gate c5 · una copia real de punta a punta con recuento de filas verificado en el
destino
, más una copia que falla a propósito en el INSERT: no puede quedar ni
una tabla huérfana ni una fila fantasma. El segundo caso es el que vale.

🏁 C·5 · DESBLOQUEADO (2026-08-16, tarde) — los dos bloqueantes CAYERON

Lo que sigue debajo es el diagnóstico, que se conserva porque explica el porqué. El estado hoy es otro:

bloqueanteestado
Spark no tiene el driver de PostgreSQL🏁 spark-k8s:4.1.2-carbon1 desplegada y cargandoc5-0-probe-motor.ts VERDE
la credencial iría en el TEXTO del SQL🏁 CarbonJdbcConnectionProvider: viaja carbon.face.token, no la contraseña
la descomposición con compensación⏭️ lo único que queda

⭐ Y el ① se cerró con una imagen custom, no con --packages: el JVM resuelve los drivers JDBC al arrancar, así que --packages no sirve para esto. Lo documenta el propio operador de Spark.

⛔⛔ El diagnóstico original (2026-08-16, mañana) — y la bifurcación que era FALSA

Se intentó descomponer el CTAS en la puerta. No queda limpio, y el motivo no es el CTAS. Medido:

CREATE TEMPORARY VIEW … USING jdbc OPTIONS (…, driver 'org.postgresql.Driver')
  → FAILED: java.lang.ClassNotFoundException: org.postgresql.Driver

Spark no tiene el driver de PostgreSQL. El CR carbon-connect (namespace spark) declara exactamente dos paquetes:

--packages org.apache.iceberg:iceberg-spark-runtime-4.1_2.13:1.11.0,
           com.dremio.iceberg.authmgr:authmgr-oauth2-runtime:1.1.1

⭐⭐ Y esto deshace la bifurcación «descomponer vs pivotar a 1.3.0»: ninguna de las dos desbloquea C·5. Son problemas independientes y hacían falta los dos medidos para verlo:

qué arregla¿desbloquea C·5?
1.3.0el stage-create ⇒ CTAS entre tablas Iceberg⛔ no — el problema es LEER de Postgres
Descomponer en la puertaevita necesitar el CTAS del catálogo⛔ no — el INSERT necesita que Spark hable JDBC
El driver en el clústerque Spark pueda leer del origenes el que falta

⇒ El bloqueo de C·5 nunca fue el CTAS: es que el motor no puede hablar con PostgreSQL. Se ve al medir, no al razonar.

⚠️ Y hay un segundo bloqueo, de DISEÑO, que el driver no resuelve

La vía natural de descomposición —CREATE TEMPORARY VIEW … USING jdbc OPTIONS (…, password '…')— metería el secreto en el TEXTO del SQL que viaja al bridge y al motor. Eso viola el invariante de C·3 (el secreto no aparece en el SQL, ni en el plan, ni en los logs), que es justo lo que se acaba de cerrar con un gate.

⇒ Antes de C·5 hay que decidir cómo llega la credencial a Spark sin ir en la sentencia: propiedades fuera del texto en el protocolo del bridge, o que el bridge resuelva la conexión por nombre y pida él la credencial a la cara (que además la auditaría).

⏭️ Lo que C·5 necesita, en orden

  1. 🏁 El driver en el motor. No en el CR con --packages —eso no funciona para JDBC— sino en una imagen custom. Verificado cargando, con control negativo.
  2. 🏁 El transporte de la credencial. Resuelto por CarbonJdbcConnectionProvider (§ abajo).
  3. 🟡 La descomposición CREATE TABLE + carga, con su compensación. La gramática ya está (copy-sql.ts, 16 tests); falta el ejecutor y el gate.

⚠️ Y 1.3.0 pasa a ser ortogonal: mejora el camino Iceberg↔Iceberg y sigue mereciendo la pena por eso, pero no es prerrequisito de la ingesta.

⭐⭐⭐ Cómo se resolvió ②: la credencial no viaja — viaja el DERECHO a pedirla

CarbonJdbcConnectionProvider se engancha en el SPI JdbcConnectionProvider de Spark, que llama a getConnection justo antes de abrir la conexión al origen. Ése es el único instante en el que la credencial hace falta, así que es el único en el que existe.

Por las opciones de la sentencia viajan cuatro cosas, y ninguna es un secreto del cliente: carbon.connection, carbon.face.url, carbon.workspace y carbon.face.token — un token de la cara, corto, revocable y auditable. El proveedor lo canjea contra /api/connections/credential en el momento de conectar.

decisiónpor qué
canHandle sólo dice sí con carbon.connectionel SPI es global al proceso: sin esa marca el plugin secuestraría conexiones JDBC ajenas
no se cachea nadauna credencial cacheada convierte el TTL en un adorno; revocar CONNECTION_USE tiene que surtir efecto de verdad
password/user/token… en las opciones se descartanmisma regla que la gramática de C·2, aplicada donde ya no se puede rechazar la sentencia
el motivo de la cara se propaga tal cualdice «falta CONNECTION_USE», que es accionable; un «authentication failed» del driver mandaría a revisar la contraseña cuando el problema es un permiso

⭐ La diferencia con poner la contraseña en SparkConf no es cosmética: allí cualquier volcado de configuración se la lleva y la única defensa es spark.redaction.regex, que es una lista de nombres. Un token que además hay que canjear deja el secreto del cliente fuera del alcance de un SET -v.

El invariante de C·3 se mantiene en C·5, y no por disciplina: por construcción.

🏁 Y el cableado está MEDIDO (C·5·1) — sin necesitar ningún Postgres

scripts/ingesta/c5-1-probe-spi.ts, VERDE en las cuatro sondas. Entre «el jar está en el classpath» (C·5·0) y «Spark instancia el proveedor y le pasa mis opciones» hay dos cosas que fallan en silencio: que el servicio no se registre, y que Spark filtre las opciones que no conoce. Si cualquiera fallara, la credencial tendría que volver al texto del SQL y C·5 habría que replantearlo entero.

⭐⭐ El truco de medición: el proveedor exige carbon.face.token y lanza antes de conectar. Así que se le manda una sentencia con carbon.connection y sin token, contra una url deliberadamente muerta. Si vuelve nuestro mensaje, el cableado existe; si vuelve un error de red, Spark usó el suyo. ⇒ separa el fallo del CABLEADO del fallo de la CONEXIÓN, que es justo la distinción que un gate de punta a punta no sabe hacer — y evita tener que exponer un Postgres al clúster para saber si el enchufe está puesto.

⛔⛔ El hallazgo: que canHandle sea selectivo NO BASTA

IllegalArgumentException: JDBC connection initiated but more than one connection
provider was found. Use 'connectionProvider' option to select a specific provider.

El proveedor básico de Spark responde a todo. Así que en cuanto el nuestro dice que sí hay DOS, y Spark no elige: aborta. ⇒ toda sentencia de copia tiene que llevar connectionProvider 'carbon'.

⭐ Y lo que parecía el fallo es la prueba: si Spark no hubiera descubierto nuestro proveedor sólo habría uno y no habría empate. El gate lo mide a propósito (sonda ②·a) para que nadie vuelva a leer ese error como «el plugin no está».

⚠️ El control negativo (③) sigue siendo imprescindible y por el motivo de siempre: el SPI es global al proceso, así que un canHandle que respondiera a todo secuestraría cualquier conexión JDBC ajena del clúster. Sin la marca, el empate no se produce.

⏭️ Lo que queda de alcance: esto es el cableado. Que la cara entregue la credencial y que la copia escriba filas es el gate de C·5 — y ése sí necesita un Postgres alcanzable desde el clúster (el de C·4 es un Docker local, que GKE no ve).

📝 La GRAMÁTICA de la copia — copy-sql.ts (16 tests)

CREATE TABLE [IF NOT EXISTS] <destino> AS SELECT * FROM <conexión>.<esquema>.<tabla> [WHERE]

⚠️⚠️ Es la misma ambigüedad de C·4, y la misma solución. CREATE TABLE dst AS SELECT * FROM main.default.t es un CTAS del warehouse, y es legítimo. Interceptar por sintaxis se lo quitaría, y el fallo aparecería lejos —en una pantalla que dejó de copiar tablas internas—, no en el fichero que lo causó. ⇒ la desambiguación es de EXISTENCIA: se extrae la forma y decide el ejecutor mirando si hay una conexión con ese nombre. Hay un test que fija que el CTAS interno también tiene la forma, para que quede escrito que lo único que se lo devuelve al warehouse es esa comprobación.

El filtro barato: se exigen tres segmentos en el origen. Con dos (esquema.tabla) sería indistinguible de un CTAS interno, así que ni se consulta la base.

SELECT * y nada más: una lista de columnas se rechaza con motivo, no se ignora. Aceptarla y copiarlo todo dejaría al usuario creyendo que proyectó en el origen — la misma clase de fallo que aceptar un password desconocido en C·2.

El WHERE se parsea y se conserva aunque hoy la copia siempre sea completa. No es código muerto: es la primera de las tres reservas del §7, y es la diferencia entre añadir CDC y reescribir la copia.


🏁 C·5 · CERRADO (2026-08-16) — c5-gate-copia.ts VERDE en vivo

250 filas copiadas desde un PostgreSQL real (Cloud SQL, IP privada) al warehouse, con la suma verificada en el destino, la copia fallida sin dejar rastro, y el reintento posible. Comando en §6 del handoff.

El recuento no bastaba: se comprueba la SUMA. «Llegaron 250 filas» y «llegaron las 250 buenas» son afirmaciones distintas, y sólo la segunda dice que la copia sirve.

⭐⭐⭐ Los cuatro hallazgos, y ninguno se vio razonando

① La puerta y el motor NO tienen la misma red. La primera versión leía el esquema conectándose al origen desde la puerta (como C·4). Falló con timeout expired contra un Cloud SQL de IP privada que el clúster sí alcanza. Y no era del entorno de pruebas: en producción la puerta corre en Vercel.

El esquema se le pide a quien va a mover los datos: se crea la vista JDBC, se le hace DESCRIBE, y de ahí sale el CREATE TABLE. Tres consecuencias, y ninguna cosmética: un solo camino de red hacia el origen (si la vista se crea, la carga puede ocurrir) · los tipos llegan ya en Spark SQL, así que desapareció un mapa de tipos hecho a mano — una lista que siempre está incompleta y cuyo modo de fallo natural es un default: STRING que copia mal y no lo dice · y el fallo llega antes de crear nada, que es lo que evita la huérfana.

⚠️ C·4 sigue conectándose desde la puerta, y ahí es correcto: navegar metadata sin motor es otra necesidad.

② El guardián del editor mata CREATE OR REPLACE por el TEXTO. Protege tablas del Warehouse con datos dentro — y una vista temporal no es ninguna de las dos cosas. El nombre de la vista ya lleva sufijo aleatorio, así que se quitó el OR REPLACE.

③ ⭐⭐ Una relación de la SESIÓN DEL MOTOR hay que DECLARARLA. El plan resuelve todo nombre contra el catálogo, así que marcaba la vista temporal como tabla inexistente — y tenía razón: no está. ⇒ RunQueryOpts.fuentesDeMotor, mismo patrón que writeTarget: un hecho que el plan no puede deducir del texto y que por eso se declara. No abre nada — esos nombres no se resuelven ni se reescriben, y los objetos del Warehouse siguen pasando por la regla de siempre.

④ ⭐⭐ Se carga con MERGE, no con INSERT, y no es un rodeo. La puerta no admite INSERT («Admitidos: DELETE · MERGE · UPDATE»), y no por descuido: carbon-sql-contract.ts lo tiene como decisión pendiente. Ampliar la superficie de escritura de la plataforma para que quepa una copia habría sido decidir eso de refilón.

⭐ Y MERGE no es el sustituto pobre: es la segunda reserva del §7. Con ON false todas las filas caen en NOT MATCHED, así que hoy es un append puro; el día del incremental sólo cambia la condición, no el camino.

⛔⛔ La IDEMPOTENCIA — el censo la daba por cubierta, y no lo estaba

CREATE TABLE IF NOT EXISTS t AS SELECT … en SQL estándar, si la tabla existe, no hace nada. La primera implementación creaba (no-op) y cargaba igual, así que la segunda corrida duplicaba las filas — sin un solo error.

Es exactamente lo que el censo de C·0 llamó «modo REPLACE / idempotencia» y dio por cubierto en C·5. No lo estaba, y sólo apareció al releer el censo contra lo entregado. ⇒ La lección de método: un censo que dice «lo cubre la fase X» hay que releerlo cuando X se cierra, o se convierte en una promesa que nadie comprueba.

Hoy:

casoqué hace
la tabla existe y hay IF NOT EXISTSno toca nada, y lo dice (rows_copied: 0, con motivo)
la tabla existe sin IF NOT EXISTSfalla con motivo: la copia no reemplaza
no existecopia

⛔ Y no se ofrece un REPLACE implícito: sobre una tabla con datos es destructivo, y el editor tampoco tiene TABLE_DROP — darlo aquí sería abrir por detrás lo que la puerta niega por delante.

⭐ El gate lo mide corriendo la copia dos veces y contando: tras dos pasadas el destino sigue teniendo 250 filas. Leer el código no habría bastado — el fallo era silencioso.

⭐⭐ LOTES Y PARALELISMO — y el que casi nadie ve

fetchsize no es una optimización: es el techo de RAM. pgjdbc, por defecto, materializa el resultado entero antes de devolver la primera fila. Sin él no hay «streaming por lotes» — hay un OutOfMemory esperando a la primera tabla grande, y no falla en las pruebas porque las tablas de prueba son pequeñas. Era la otra capacidad que el censo de C·0 daba por cubierta en C·5.

El fan-out (partitionColumn + lowerBound/upperBound + numPartitions) abre N lectores por rangos. ⭐⭐ Los extremos se le piden al ORIGEN, no a Spark: un SELECT min/max sobre la vista podría resolverse escaneando la tabla entera —justo lo que se evita—, mientras que mandarlo como dbtable lo ejecuta PostgreSQL, que para eso tiene el índice.

⚠️ Y la corrección no depende de que la columna sea única: los rangos parten FILAS, no claves. Una columna repetida da particiones desiguales, no resultados malos.

⭐ Si los extremos no se pueden averiguar, se degrada a un lector en vez de abortar: el fan-out es una optimización, y cambiar «más lento» por «no funciona» sería un mal negocio.

⛔⛔ Y el umbral es configurable A PROPÓSITO (C5_FANOUT_MIN_RANGO, 100 000 por defecto). Un umbral fijo alto haría que ningún gate ejercitara nunca el camino particionado — y un camino que no se ejercita no está probado, está escrito. El gate lo baja a 1 y comprueba que con 4 lectores las 250 filas y la suma siguen exactas.

⭐⭐ CREATE TABLE … LIKE <conexión>.… — metadata-only

CREATE TABLE local LIKE pg_ventas.public.ventas   -- estructura, CERO filas

Sale casi gratis: el esquema ya se obtiene del motor antes de crear nada, así que esto es quitarle el paso de carga. ⭐ Y no necesita el catálogo federado temporal que el flujo canónico de Databricks crea y borra después — aquí la conexión ya es un securable.

⚠️ Misma ambigüedad y misma regla: CREATE TABLE x LIKE main.default.t es una forma legítima del warehouse, así que la desambiguación vuelve a ser de existencia.

⭐ El gate mide las dos mitades: cero filas y cuatro columnas — una tabla vacía sin columnas también daría cero, y aprobaría por el motivo equivocado.

La compensación, y por qué el caso que vale es el que falla

compensateIfPhantom ya existía y es exactamente la primitiva: la duda no borra. No se infiere del control-flow que la tabla no exista —un fallo puede ser un permiso, un conflicto o un corte de red—, se le pregunta al catálogo, y sólo un 404 explícito autoriza a retirar el registro. El gate mide las dos mitades: que no quede fila fantasma, y que el nombre se pueda reintentar — una huérfana lo bloquearía para siempre.


🧹 C·6 · SUPRIMIR el tubo

Retirar el worker de polling y el warehouse-writer del camino de ingesta. Suprimir, no apilar: nada de fallback que parpadea.

⚠️ El tubo está roto desde ~08-11 (el writer pide a Lakekeeper un warehouse por inquilino que ya no existe). No se arregla: se sustituye. Arreglarlo para luego borrarlo es trabajo que se tira.

Gate c6 · un trinquete en CI que falle si alguien vuelve a llamar al writer desde
el camino de ingesta, y el censo de C·0 con todas sus filas resueltas.


3 · ✅ LA DEFINICIÓN DE LISTO

Una fase está LISTA cuando cumple las siete. No hay listo parcial.

criteriopor qué está aquí
1Verde en frío: tests unitarios y tsc limpioel suelo, no el techo
2Verde EN VIVO, contra el sustrato real, con su comando en el doc«funciona en tests» no es funciona
3Todo gate lleva CONTROL NEGATIVOun gate de concurrencia sin conflicto aprueba siempre; un control que falla demasiado pronto no distingue nada
4Se mide por la CARA, no por el upstreampreguntar a Gravitino directamente devuelve vacío sin un solo error
5Ningún fallo silencioso: si no se escribió, el estado no dice éxitomedido tres veces el 08-16 — un job con ✅ 100 filas y cero escritas, un alta active sin miembros, un trinquete verde sobre un alta rota
6Un trinquete que impida la regresión, y que rompa por el motivo correctoun principio en un comentario no es un gate
7Documento y memoria al día en la misma iteraciónlo rancio es la primera fuente de confusión

Y dos criterios de rechazo, que anulan lo anterior:

  • Un secreto que aparezca en cualquier texto persistido invalida la fase, aunque todo lo demás esté verde.
  • Una capacidad del tubo que desaparezca sin figurar en el censo de C·0 como abandonada a propósito.

4 · El orden, y por qué es ése

C·0 censo ──► C·1 securable ──► C·2 gramática ──► C·3 vending ──► C·4 SHOW ──► C·5 copia ──► C·6 suprimir
              └────────── verificable en pantalla sin tocar datos ──────────┘

C·1-C·4 se pueden demostrar sin mover un byte, lo cual permite enseñar el camino antes de arriesgar nada. C·5 es el único que escribe. C·6 va al final por una razón concreta: el tubo, aunque roto, es la única documentación viva de lo que la ingesta sabe hacer.


5 · 🪤 Riesgos conocidos, con su mitigación

riesgomitigación
El INSERT falla y deja una tabla que no se puede borrarcompensación desde C·5, con el caso de fallo en el gate
La credencial acaba en el clúster o en un loginvariantes de C·3 + test que la busca
⚠️La API de gestión de Gravitino no está expuesta (404)ninguna fase depende de ella; el namespace se crea por IRC (POST /v1/namespaces, verificado)
⚠️Una operación colgada envenena la sesión de Sparkmedir siempre en sesión limpia; nunca encadenar medidas tras un fallo
⚠️El destino nace en {env}_w_{tenant} (un nivel)no volver al punto: el multinivel es lo que impedía nacer
⚠️Suprimir el tubo antes de cubrir sus capacidadesC·0 es prerrequisito, no papeleo

6 · Lo que este approach NO decide

Heredado de fundamentos §6, y sigue abierto:

  1. ¿La conexión se puede CONSULTAR o sólo copiar? — 🏁 DECIDIDO y entregado (F·1, 2026-08-17): sí, y es la cuña. SELECT … FROM <conexión>.<esq>.<tabla> con zero-copy medido. Cambió la tesis del producto, tal y como esta línea avisaba.
  2. ¿El destino puede ser cualquier catálogo/esquema o siempre el namespace opaco?
  3. La forma del incremental.

⏭️ Y lo que la decisión ① destapó — la serie continúa en FD

📎 fd-foreign-database-approach.md

Consultar sin copiar funciona, pero se autoriza con CONNECTION_USE — el permiso de usar la conexión—, porque no existe una tabla foránea donde colgar el SELECT. Medido por o1-inventario-operaciones.ts: sólo el owner alcanza una conexión, y en un inquilino un admin no puede usar las que él mismo creó.

⇒ La entrega siguiente de esta misma iteración pone la pieza que SQL/MED, Databricks y Snowflake tienen y aquí falta: el contenedor espejado. Aquí toma la forma de Snowflake —FOREIGN DATABASE, database.schema.object— porque catálogo ya nombra otra cosa en Carbon.


7 · Para no cerrarle la puerta al incremental

Sin diseñarlo, tres cosas que C·5 debe dejar preparadas —y que cuestan poco ahora y mucho después—:

  • que la lectura de origen admita una condición (WHERE cursor > …), aunque hoy no se use;
  • que la escritura pueda ser MERGE y no sólo INSERT, aunque hoy siempre sea append;
  • que la ejecución deje rastro reanudable (qué se leyó hasta dónde), aunque hoy nadie lo lea.

⚠️ Ninguna de las tres se implementa en C·5. Se deja el sitio, que es distinto — y es la diferencia entre añadir CDC y reescribir la copia.