Back to deblo
deblo

Once bugs entre el submit y el ship: una sesión de envío doble-store de cinco horas, recorrida bug por bug, desde los podspecs RCT-Folly hasta las páginas de memoria de dieciséis kilobytes

Once bugs distintos encontrados y enviados en una sola sesión doble-store de cinco horas, desde el podspec RCT-Folly bajo Expo SDK 54 hasta la advertencia Android sobre páginas de memoria de dieciséis kilobytes. Bug por bug, qué se rompió, cómo fue el fix, cuáles requirieron capa de persistencia de seguimiento, y cuál aplazamos limpiamente a versionCode 3.

Juste A. Gnimavo (Thales) & Claude | May 27, 2026 41 min deblo
EN/ FR/ ES
debloclaude-opus-4.7claude-codeapple-app-storegoogle-playreact-nativeexpo-sdk-54rn-0.81-new-architecturereact-native-iaplivekitstorekit-2app-store-server-api-v2jwsx5c-chainpython-josedeviceeventemittergradlemissing-dimension-strategykotlinpatch-packageexpo-config-plugin16kb-page-sizendksentry-sourcemapspost-mortemdual-store-submit

Por Thales (CEO, ZeroSuite) y Claude Opus 4.7 — instancia Claude Code

La sesión comenzó a las 20:00 UTC del 27 de mayo de 2026, con lo que creíamos era una tarea de cierre. La integración Apple In-App Purchase había aterrizado dos días antes en las sesiones 252 y 253. El rediseño del modal de privacy y el hard-delete de pricing iOS habían aterrizado en la sesión 254. El fix del host de deep-link Android y su simétrico contraparte iOS Universal Links estaban ambos en main. Teníamos cuatro cuentas demo pobladas y listas para el reviewer Apple. El plan de la noche: subir versionCode y buildNumber, lanzar expo prebuild, archivar en Xcode, subir mediante Organizer, y hacer clic en Submit en App Store Connect. Luego replicar el mismo flow para Android: gradlew bundleRelease, subir a Play Console, clic en Submit. Dos stores, una sesión, tres horas quizás.

La sesión duró cinco horas. Terminó a las 01:00 UTC del 28 de mayo con ambas stores en estado submitted, in review, pero solo después de que once bugs distintos hubieran sido encontrados y corregidos en directo, en secuencia, cada uno enviado antes de abordar el siguiente. Algunos bugs estaban en nuestro código. Algunos en los SDK de los que dependemos. Algunos en nuestra comprensión de cómo el reviewer de Apple iba a ejercer realmente la app. Uno estaba en un archivo de configuración que Apple no había regenerado cuando añadimos una nueva capability seis meses antes. El fix más largo tardó noventa minutos; el más corto, tres.

Este post recorre cada bug en orden, nombra lo que era, lo que costó, y cómo se veía el fix. Ocho de los once se enviaron en 1.0.6 directamente. Tres requirieron trabajo de persistencia que se envió como commit de seguimiento más tarde esa noche. Ninguno era aplazable más allá del submit; todos eran bloqueantes en alguna dirección.

La lista condensada de bugs es: podspec RCT-Folly ausente bajo las dependencias preconstruidas RN 0.81, hermano de fallback de Expo Router requerido, capability Associated Domains ausente del provisioning profile, mala detección del entorno sandbox versus production de StoreKit 2, verificación JWS python-jose esperando un formato PEM diferente, listener DeviceEventEmitter montado en la pantalla equivocada, pantalla credits iOS dividida entre wallet y contador antiguo de credits, timeout de subida de sourcemaps Sentry, ambigüedad de variant Gradle de React Native IAP entre flavors play y amazon, error de compilación Kotlin de React Native IAP bajo RN 0.81 New Architecture, error soft de página de memoria dieciséis kilobytes en Play Console. El orden en que los escribo es el orden en que los encontramos, que es también el orden en que surgirían para cualquier equipo recorriendo el mismo camino de upgrade en la misma matriz SDK la misma semana.


Parte 1 — Bug 1: pod install no encuentra RCT-Folly

La primera señal de que esta no sería una sesión limpia llegó un minuto después de ejecutar npx expo prebuild --platform ios. El prebuild generó ios/Deblo.xcworkspace, el directorio Pods/ se escaffoldó, y cd ios && pod install falló con:

Unable to find a specification for `RCT-Folly`

RCT-Folly es el fork de Folly que React Native viene incluyendo. Hasta React Native 0.78 se enviaba como una spec CocoaPods autónoma de la que dependías desde cualquier módulo que necesitara los tipos Folly. A partir de 0.78, el equipo de React Native introdujo un camino de dependencias React Native preconstruidas (RCT_USE_RN_DEP=1) que empaqueta Folly junto con el resto del runtime React Native en un único artefacto, eliminando la necesidad de que los módulos individuales declaren una línea s.dependency "RCT-Folly".

El problema es que no todas las specs CocoaPods del ecosistema han alcanzado el cambio. El podspec de [email protected], la versión que teníamos instalada para la integración StoreKit 2, sigue incluyendo:

rubyif ENV['RCT_NEW_ARCH_ENABLED'] == '1'
  s.dependency "RCT-Folly"
end

Teníamos RCT_NEW_ARCH_ENABLED=1 porque estamos en la New Architecture en toda la app. Teníamos RCT_USE_RN_DEP=1 porque ese es el default de Expo SDK 54 para las dependencias React Native preconstruidas. La combinación hace que react-native-iap declare una dependencia sobre un podspec que el camino preconstruido no expone, y CocoaPods falla sin ningún diagnóstico sobre el porqué.

El fix es hacer opt-out de este build de las dependencias React Native preconstruidas en iOS. El plugin Expo que controla esto es expo-build-properties, configurado en app.json:

json{
  "expo": {
    "plugins": [
      [
        "expo-build-properties",
        {
          "ios": {
            "buildReactNativeFromSource": true
          }
        }
      ]
    ]
  }
}

buildReactNativeFromSource: true fuerza a RN a compilar desde las fuentes en iOS en lugar de usar el tarball preconstruido. La compilación es más lenta (aproximadamente 60 segundos añadidos a la primera archive del día) pero significa que RCT-Folly se expone como un podspec normal del que los módulos aguas abajo pueden depender. El flag sobrevive a todos los futuros prebuilds porque vive en app.json.

La instalación del package en sí fue un simple npm install expo-build-properties en la raíz del monorepo, recogido por el siguiente prebuild. Tiempo gastado: cuarenta y cinco minutos (la mayoría leyendo las fuentes de react-native-iap y expo-build-properties para confirmar el diagnóstico antes de commitear). Enviado en el commit 8baf4f6.

La lección es incómoda. Las dependencias nativas declaran requisitos duros en la capa podspec que los bumps de versión React Native pueden invalidar silenciosamente. El único camino al descubrimiento es pod install fallando con un mensaje opaco. Para equipos que corren en el borde rezagado de las versiones React Native (nosotros, tres semanas después de la estabilización de 0.81), esta clase de bug es esperable. Para equipos en 0.76 o 0.77, el bug aún no existiría — lo encontrarían al upgradar.


Parte 2 — Bug 2: Expo Router requiere un hermano de fallback

Con pod install desbloqueado, el siguiente fallo fue en tiempo de build de Xcode. Metro empezó a bundlear, y unos segundos después:

Error: app/pricing.ios.tsx does not have a fallback sibling.

Habíamos introducido archivos de pricing específicos de plataforma en la sesión 254 como parte del hard-delete iOS 3.1.1 (la guideline de Apple que prohíbe mostrar precios de contenido digital fuera de IAP). app/pricing.ios.tsx retorna un componente declarativo <Redirect href="/" />; app/pricing.android.tsx renderiza la grilla de precios FCFA completa. No había un app/pricing.tsx.

Expo Router enumera las rutas al boot y requiere un .tsx base para cada archivo extendido por plataforma, incluso cuando ambos archivos extendidos por plataforma existen. El razonamiento es que Metro elige el archivo específico de plataforma en tiempo de bundle, pero la tabla de rutas del router se construye antes de que Metro resuelva las plataformas, así que necesita una base sobre la que razonar. Una base ausente se manifiesta como un error en tiempo de build específico a la plataforma cuyo bundle se está construyendo.

El fix es un app/pricing.tsx de cinco líneas que también retorna un <Redirect href="/" />. Metro elige .ios.tsx en iOS y .android.tsx en Android, así que el archivo base solo se renderiza en plataformas que no enviamos (web, que no apuntamos). El archivo añadido en el commit 8baf4f6 junto al plugin de build-properties:

tsximport { Redirect } from 'expo-router';

export default function PricingFallback() {
  return <Redirect href="/" />;
}

Tiempo gastado: tres minutos desde el error hasta el commit. La lección es microscópica — si creas app/foo.ios.tsx, crea también app/foo.tsx — pero el mensaje de error de Expo Router no dice por qué el fallback es requerido, solo que lo es. Una futura versión de Expo Router podría relajar esto; nos beneficiaremos del cambio sin hacer nada. Hoy, añadimos el archivo de cinco líneas.


Parte 3 — Bug 3: provisioning profile sin la capability Associated Domains

Con el build ahora compilando, Xcode produjo un error fresco en el paso de firma:

Provisioning profile "iOS Team Provisioning Profile: ai.deblo.app" doesn't include the Associated Domains capability.

Associated Domains es la capability iOS que alimenta los Universal Links — el equivalente del lado iOS de los Android App Links. Habíamos añadido applinks:deblo.ai a app.json -> ios.associatedDomains en el commit 9ec9b2b seis meses antes como parte del trabajo de Universal Links iOS. El prebuild escribió correctamente el archivo Deblo.entitlements con la nueva capability. El provisioning profile del Developer Portal de Apple para el App ID ai.deblo.app, sin embargo, había sido generado antes de que esa capability existiera, y Apple no autorregenera los provisioning profiles cuando se añaden capabilities — el desarrollador debe dispararlo.

El fix es un clic en Xcode: Signing & Capabilities → botón "Try Again" bajo el error del provisioning profile. Xcode golpea el Developer Portal, añade Associated Domains al App ID, regenera el provisioning profile, y lo re-descarga. El build recoge el nuevo profile y procede.

Tiempo gastado: cinco minutos incluyendo el viaje de ida y vuelta al portal de Apple. La lección es que las adiciones de capability en app.json son un commit parcial — actualizan el archivo de entitlements y los metadatos de prebuild, pero el desarrollador debe sincronizar manualmente el provisioning profile, una vez por capability, disparando el flow "Try Again" de Xcode. No hay forma CI-friendly de hacer esto; el developer portal no tiene una CLI para este caso específico. Aceptamos el paso manual como un coste semestral — cada nueva capability vale una interacción Xcode adicional.

La lección generalizada: cualquier adición en app.json o en los entitlements necesita un gate explícito "el primer build después de la adición debe correr en una máquina de desarrollador con Xcode y acceso al Developer Portal de Apple" antes de la próxima archive. Habíamos pasado ese gate informalmente hace seis meses cuando añadimos associatedDomains; no habíamos relanzado una archive entre ese momento y esta noche, así que el paso manual no había surgido hasta esta noche. Un proceso limpio lo habría hecho surgir el día que añadimos la capability.


Parte 4 — Bug 4: mala detección sandbox versus production de StoreKit 2

Con el build ahora firmado y subido a App Store Connect, el envío iOS estaba efectivamente hecho desde la perspectiva de Xcode. Pasamos a pruebas IAP en vivo en el iPhone sandbox-firmado que el CEO había configurado en la Fase 1.4 de la integración IAP.

La primera compra sandbox corrió limpiamente del lado Apple. StoreKit 2 retornó una transacción verificada. La app móvil llamó al endpoint backend /api/credits/apple-iap/verify con el jwsRepresentation de la transacción. El backend logueó:

Apple receipt invalid for tx 2000001178213814: Apple API 401 (env=Production). Likely sandbox/prod mismatch.

La respuesta 422 volvió a la app móvil, el wallet no se acreditó, y el toast de celebración que habíamos construido para el camino de éxito de IAP no se disparó. La tarjeta sandbox del CEO había sido cargada con noventa y nueve centavos (reembolsada por Apple más tarde — las compras sandbox se auto-reembolsan), pero el wallet mostraba sin cambios.

La causa raíz estaba en iapService.ts. La detección del entorno para decidir qué endpoint de verificación Apple usar (sandbox versus production) era heredada de la era StoreKit 1, cuando los receipts eran blobs PKCS7 base64 que contenían la cadena literal "sandbox" en alguna parte del plaintext. La detección era:

tsconst env = transactionReceipt.includes('sandbox')
  ? 'Sandbox'
  : 'Production';

StoreKit 2, que react-native-iap@12 usa en iOS 15+, no retorna un receipt PKCS7. Retorna un jwsRepresentation — una JSON Web Signature cuyo payload está base64-encoded y cuyas claims iss y bid identifican al emisor pero no al entorno. La cadena "sandbox" no aparece en la JWS para una transacción sandbox. La detección siempre caía a 'Production', el backend siempre golpeaba el endpoint production primero, y el endpoint production siempre retornaba 401 porque la transacción había sido emitida realmente por la infraestructura sandbox de Apple.

Teníamos dos caminos para fixear. Podíamos actualizar iapService.ts para extraer el entorno del payload JWS (la librería App Store Server provee un helper). O podíamos hacer al backend resiliente a los bugs de detección de entorno del lado cliente intentando el entorno sugerido por el cliente primero y haciendo fallback al entorno opuesto en 401 o 404. El segundo es estrictamente mejor porque funciona para el dev-client, TestFlight y builds de producción independientemente de los bugs del lado cliente, y porque la propia documentación legacy verifyReceipt de Apple prescribía este patrón de fallback en la era StoreKit 1.

El fix backend está en backend/app/services/apple_iap.py. La función verify_transaction fue refactorizada para extraer un helper interno _verify_against_apple que retorna None en 401/404 (tratándolos como "entorno equivocado, intenta el otro") y re-lanza en cualquier otro error. El wrapper intenta el entorno sugerido por el cliente primero, luego hace fallback al entorno opuesto una vez:

pythonasync def verify_transaction(jws_rep: str, env_hint: str) -> AppleTxn:
    primary = env_hint
    fallback = "Sandbox" if env_hint == "Production" else "Production"

    result = await _verify_against_apple(jws_rep, primary)
    if result is None:
        result = await _verify_against_apple(jws_rep, fallback)
    if result is None:
        raise InvalidReceipt(f"Apple returned 401/404 in both envs")

    return result

El coste en estado estable es una llamada API Apple adicional por transacción sandbox (despreciable) y cero coste adicional para transacciones de producción (el hint del cliente será generalmente correcto después de que arreglemos iapService.ts, pero el backend ya no depende de que el cliente tenga razón). Tiempo gastado: treinta minutos desde el error hasta el fix desplegado, incluyendo leer la documentación de App Store Server API V2 para confirmar que 401 y 404 son las señales correctas para "entorno equivocado". Enviado en el commit 8baf4f6.

La lección se generaliza más allá de StoreKit. Cuando verifiques receipts o tokens emitidos por una parte externa, no dependas del cliente para que te diga qué endpoint de validación golpear. El cliente es el narrador menos confiable de la cadena. Intenta el hint, haz fallback al código de error predecible, trata el camino de fallback como coste de estado estable en lugar de excepcional.


Parte 5 — Bug 5: crash de verificación JWS sobre PEM de certificado versus PEM SubjectPublicKeyInfo

Con el Bug 4 arreglado, el backend pasó el mismatch de entorno (Production → 401, fallback a Sandbox → 200, payload JWS recuperado de Apple). Luego crasheó durante el siguiente paso: verificación de firma JWS contra el certificado hoja en la cadena x5c.

El stack trace terminaba en:

jose.exceptions.JWKError: Valid PEM but no BEGIN/END delimiters for a private key

El mensaje de error era activamente engañoso. No estábamos intentando cargar una clave privada; estábamos intentando verificar una firma contra una clave pública extraída de un certificado hoja. La frase "no BEGIN/END delimiters for a private key" nos envió brevemente a mirar la variable de entorno APPLE_IAP_ROOT_CA_PEM, el setting APPLE_IAP_AUDIENCE, y el env de Easypanel producción — ninguno era la causa.

La causa real estaba en apple_iap.py:325, en parse_jws_signed_payload:

pythonleaf_pem = leaf.public_bytes(serialization.Encoding.PEM)
payload = jwt.decode(jws_rep, key=leaf_pem, algorithms=["ES256"], ...)

leaf es un objeto x509.Certificate representando el cert hoja en la cadena x5c de la JWS. El método public_bytes(Encoding.PEM) retorna el PEM del certificado (-----BEGIN CERTIFICATE-----), no el PEM de la clave pública. El constructor JWK de python-jose quiere un PEM SubjectPublicKeyInfo (-----BEGIN PUBLIC KEY-----), no un certificado. El error engañoso "no private key delimiters" viene del parser PEM de python-jose que falla en el prefijo de certificado y cae en un camino de código genérico "esto no es una clave" que menciona claves privadas por razones históricas.

El fix es un cambio de una sola línea. En lugar de retornar el PEM del certificado, extraer la clave pública del certificado y retornar su PEM SubjectPublicKeyInfo:

pythonleaf_public_key = leaf.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
payload = jwt.decode(jws_rep, key=leaf_public_key, algorithms=["ES256"], ...)

Tiempo gastado: cuarenta minutos, la mayoría persiguiendo el mensaje de error engañoso antes de leer la fuente de python-jose para encontrar la expectativa real de formato PEM. Enviado en el commit a139474.

La lección aquí tiene dos partes. La lección estrecha es que public_bytes(Encoding.PEM) de la librería cryptography sobre un x509.Certificate retorna el certificado, no la clave. Para obtener la clave, llamas a .public_key().public_bytes(...). La lección más amplia es sobre los mensajes de error: "no BEGIN/END delimiters for a private key" de python-jose es un error fall-through que se dispara cuando el parser no puede interpretar la entrada como ninguno de los formatos que espera, con el mensaje nombrando el último formato que intentó en lugar del formato que el usuario realmente proveyó. Cuando depures errores de librerías criptográficas, el mensaje a menudo apunta a la cosa equivocada porque el parser recorre los formatos en un orden fijo.

Añadimos un comentario centinela cerca del fix para documentar esto para futuros lectores:

python# leaf.public_bytes() returns the CERTIFICATE PEM, not the public key.
# python-jose wants the SubjectPublicKeyInfo PEM. Misleading error if wrong.

Parte 6 — Bug 6: listener DeviceEventEmitter en la pantalla equivocada

El flow IAP ahora funcionaba de extremo a extremo en el dispositivo. Apple retornaba 200 en el env sandbox (vía fallback), el backend verificaba la JWS, el wallet se acreditaba en la base de datos, la transacción se registraba con el usuario, producto y monto correctos. Veíamos la fila en Postgres. No veíamos el cambio en la app.

La UI móvil mostraba la misma píldora de saldo wallet antes y después de la compra. El toast de celebración user-facing — "Wallet topped up successfully" — no se disparaba. La ruta /wallet mostraba el saldo viejo. Tirar para refrescar lo actualizaba. El usuario, en ausencia de pull-to-refresh, pensaría que la compra había fallado silenciosamente.

La causa raíz era un listener montado en el componente React Native equivocado. iapService.ts línea 164 emite un evento DeviceEventEmitter después de un verify exitoso:

tsDeviceEventEmitter.emit('IAP_WALLET_TOPPED_UP_EVENT', {
  newBalanceUsdMicro: result.newBalanceUsdMicro,
  productId: result.productId,
});

El único listener para ese evento vivía en app/credits.tsx:

tsxuseEffect(() => {
  const sub = DeviceEventEmitter.addListener('IAP_WALLET_TOPPED_UP_EVENT', () => {
    restoreSession();
  });
  return () => sub.remove();
}, []);

Dos problemas con esto. Primero, restoreSession() actualiza el objeto user autenticado (que lleva un contador credits legacy) pero no llama a useWalletStore.refreshBalance() (que es la fuente de verdad para el saldo wallet real en USD-micro). La píldora wallet está bindeada al store, no al objeto user, así que no se actualiza. Segundo, el listener solo se monta cuando el usuario está en app/credits.tsx. Si el usuario ha navegado fuera a mitad de compra, o si Apple reproduce una transacción en cola al cold-boot de la app (lo que StoreKit 2 hace para transacciones interrumpidas por problemas de red en la sesión previa), el evento se dispara y ningún listener lo atrapa.

El fix es conceptual en dos líneas, literal en diez. Añadimos un listener global en _layout.tsx, montado en la raíz del árbol de navegación, que llama a useWalletStore.getState().refreshBalance() en cada evento IAP:

tsxuseEffect(() => {
  const sub = DeviceEventEmitter.addListener('IAP_WALLET_TOPPED_UP_EVENT', () => {
    useWalletStore.getState().refreshBalance();
  });
  return () => sub.remove();
}, []);

El listener de la pantalla credits se quedó porque maneja UX local a la pantalla (la animación de celebración, el reset de estado de botón). Los dos listeners son ahora complementarios: el global actualiza el store wallet independientemente de qué pantalla esté montada; el local a la pantalla maneja el feedback específico de pantalla cuando el usuario está en credits.

Los replays al boot funcionan también ahora. Cuando _layout.tsx se monta al cold boot, el listener global está registrado antes de que StoreKit 2 tenga oportunidad de reproducir las transacciones en cola. Cuando una transacción reproducida pasa por el camino verify, el evento se dispara, el listener global lo atrapa, el store wallet se refresca, y la píldora se actualiza — todo sin que el usuario abra nunca la pantalla credits.

Tiempo gastado: veinticinco minutos desde la observación hasta el fix desplegado. Enviado en el commit 4bd3308.

La lección se generaliza a cualquier flow de eventos fire-and-forget. Si el listener está montado en una pantalla específica, el evento solo se dispara cuando esa pantalla está montada. Si el caso de uso incluye replays al boot, deriva navegacional o eventos en segundo plano, el listener pertenece al layout raíz, no a una pantalla específica. Los listeners locales a pantalla son correctos para UX local a pantalla (animaciones, estados de botón); los listeners al layout raíz son correctos para estado global (el wallet, el usuario, el token de auth).


Parte 7 — Bug 7: pantalla credits iOS dividida entre wallet y contador antiguo de credits

El flow IAP funcionaba ahora técnicamente. La píldora wallet se actualizaba. El toast se disparaba. El CEO hizo un smoke test, envió la grabación de pantalla al reviewer Apple, y esperó la próxima decisión Apple.

Luego abrió /credits y notó que la pantalla mostraba números contradictorios. La píldora wallet en la parte superior de cada pantalla, incluyendo /credits, leía $1,516.80 (el saldo USD real desde useWalletStore). La tarjeta de saldo dentro de /credits leía 166 credits (el campo legacy user.credits desde el store de auth, en unidades antiguas de credit que ya no corresponden a dinero real en iOS). Después de una compra IAP exitosa de $1,99, la píldora wallet pasó correctamente de $1,516.80 a $1,518.79. El contador de credits dentro de /credits se quedó en 166. Para el usuario, esto parece como si la compra no hubiera acreditado, aunque lo hizo, porque la pantalla está mostrando la fuente de verdad equivocada.

La división es histórica. Antes de la integración Apple IAP, cada transacción wallet (top-up mobile money XPAYE, top-up tarjeta ZeroFee) actualizaba tanto el saldo wallet USD-micro como el contador de credits derivado. El contador de credits es una derivación de presentación — credits por USD, con tasas diferentes para K12 versus Pro — y el camino de código legacy lo mostraba en la pantalla credits porque era históricamente la moneda user-facing. Después de la integración Apple IAP, los usuarios iOS hacen top-up al wallet directamente en USD-micro, sin derivación de credits en tiempo de top-up. El contador de credits permanece como un campo derivado para el consumo (un mensaje de chat debita N credits, donde N se calcula desde el saldo wallet actual del usuario y la tasa por feature en tiempo de consumo). Pero el contador de credits ya no es la cosa correcta a mostrar en iOS en tiempo de top-up, porque el usuario acaba de pagar USD reales y quiere ver USD.

El fix es un retrabajo de labels iOS-only en credits.tsx. El flow Android XPAYE / ZeroFee no se toca porque sigue usando el modelo de display credits-first. El camino iOS ahora lee condicionalmente useWalletStore más renderComponents (el formateador de moneda) y muestra el saldo USD-micro real en la moneda de display del usuario:

tsx{Platform.OS === 'ios' ? (
  <BalanceCard
    title={t('deblo.credits.ios_wallet.balance_title')}
    amount={renderComponents(walletStore.balanceUsdMicro, userCurrency)}
    hint={t('deblo.credits.ios_wallet.balance_hint')}
  />
) : (
  <BalanceCard
    title={t('deblo.credits.android_credits.balance_title')}
    amount={`${user.credits} ${t('deblo.credits.credits_unit')}`}
    hint={t('deblo.credits.android_credits.balance_hint')}
  />
)}

Los labels fueron reescritos en paralelo. El título del header pasa de "Top up credits" a "Top up wallet" en iOS. El texto de hint cambia de "Your credits don't expire" a "Your balance is real money — top up anytime, never expires". El label del botón de compra de pack cambia de "Buy" a "Recharge" (que es también el verbo francés en ambos registros). El CTA desde /wallet cambia de "Buy credits" a "Recharge wallet" en EN; la versión FR ya era "Recharger".

Cuatro nuevas claves i18n fueron añadidas bajo deblo.credits.ios_wallet en en.json y fr.json para la formulación iOS-específica. Las versiones francesas incluyen los caracteres acentuados conforme a nuestra regla permanente cross-proyecto según la cual el contenido francés debe ser ortográficamente correcto independientemente de cómo el usuario escriba en el chat.

Tiempo gastado: cuarenta y cinco minutos incluyendo escribir las claves i18n, reescribir los labels dos veces (la primera versión tenía una mención a "credits" dentro del hint iOS, que atrapamos y eliminamos), y un pequeño diff visual para confirmar que el layout de la tarjeta iOS coincidía con el de Android. Enviado en el commit eed54bc.

La lección es sobre migración de fuente de verdad. Cuando divides un sistema wallet (dinero real) de un sistema credits (presentación derivada), las pantallas que previamente mostraban credits deben ser auditadas para saber si están mostrando credits (lo que ahora es incorrecto en el camino wallet) o gastando credits (lo que sigue siendo correcto porque los credits son la unidad de consumo). La pantalla credits en iOS estaba haciendo ambas cosas — mostrando el contador de credits legacy arriba, pero debitando del wallet en las transacciones reales. La mitad de display era el bug.


Parte 8 — Bug 8: timeout de subida de sourcemaps Sentry

Con los seis fixes previos desplegados, ejecutamos la archive de Xcode por última vez, listos para subir mediante Organizer. La archive tuvo éxito. La fase de subida de sourcemaps Sentry que corre al final de la archive falló:

error: sentry-cli [26] Failed to open/read local data from file/application (Recv failure: Operation timed out)

La fase de subida de sourcemaps de Sentry está cableada mediante el plugin Sentry React Native durante el build de Xcode. Ejecuta sentry-cli sourcemaps upload --release [email protected]+5 dist/ al final de la archive, lo que streamea el bundle generado y los sourcemaps al endpoint de ingest de Sentry. El endpoint hizo timeout, la subida falló, y la archive falló porque la fase de subida está cableada como un paso requerido en las Build Phases de Xcode.

Teníamos dos opciones. Arreglar la subida (debug de red, retry, cambiar endpoint). O saltarse la subida y aceptar el coste (números de línea minificados en los crash reports de 1.0.6 hasta que los sourcemaps sean backfilleados más tarde). El CEO estaba en la ventana de submit, el reviewer Apple no miraría los sourcemaps, y el coste de saltar era tolerable.

El fix es una variable de entorno de scheme de Xcode: SENTRY_ALLOW_FAILURE=true puesta en Edit Scheme → Archive → Arguments → Environment Variables. La fase de build de Sentry lee esta variable y, cuando está puesta, loguea un warning en lugar de hacer fallar el build:

warning: Sentry sourcemap upload failed (SENTRY_ALLOW_FAILURE=true): timeout

La archive se completó. El build se subió a App Store Connect. Los sourcemaps para 1.0.6 iOS no se backfillearon en esta sesión (están en cola como tarea post-launch — "cd deblo-mobile/apps/deblo && npx sentry-cli sourcemaps upload --release [email protected]+5 dist/" — en la roadmap cockpit). El coste es que cualquier crash capturado para usuarios 1.0.6 iOS entre el día del submit y el día del backfill mostrará números de línea JS minificados en lugar de nombres de función mapeados a la fuente. Tolerable para la primera semana de producción. No tolerable para la segunda semana.

Tiempo gastado: diez minutos incluyendo leer la fuente del plugin Sentry para confirmar el nombre de la variable de env. No es un cambio de código — vive en Deblo.xcodeproj/xcshareddata/xcschemes/Deblo.xcscheme y no se commitea a git por principio (los settings de scheme de Xcode cambian frecuentemente y son a menudo locales).

La lección es procedimental. Cuando tienes una dependencia externa no-bloqueante en un pipeline de build, construye el kill switch antes de necesitarlo. Sentry tiene SENTRY_ALLOW_FAILURE. La mayoría de herramientas CI tienen flags equivalentes. Saber que el flag existe antes de estar en una ventana de submit ahorra una hora.


Parte 9 — Bug 9: ambigüedad de variant Gradle de React Native IAP

Con iOS enviado, pasamos a Android. El primer comando — cd android && ./gradlew bundleRelease — falló con:

Could not resolve project :react-native-iap.
... cannot choose between amazonReleaseRuntimeElements and playReleaseRuntimeElements

React Native IAP publica dos flavors Android: play y amazon. El flavor play envuelve Google Play Billing; el flavor amazon envuelve el SDK de Amazon Appstore. Ambos son targets de build válidos. El algoritmo de resolución de variants de Gradle ve la dependencia sobre react-native-iap y encuentra dos caminos de resolución igualmente válidos, se rehúsa a adivinar, y pide un hint al consumidor.

El hint es una declaración missingDimensionStrategy en el bloque defaultConfig {} de android/app/build.gradle:

groovyandroid {
  defaultConfig {
    missingDimensionStrategy 'store', 'play'
  }
}

Esto le dice a Gradle: cuando una dependencia declara la dimensión de flavor store y el consumidor no lo hace, por defecto a 'play'. El bundleRelease después de añadir esta línea tuvo éxito para el target Google Play Store. (Para Amazon Appstore, usaríamos 'amazon'.)

Esta noche, añadimos la línea directamente a android/app/build.gradle para desbloquear el submit. El CEO ejecutó el build, tuvo éxito, el AAB se subió a Play Console. El bug volvió a surgir al día siguiente en un expo prebuild limpio, porque expo prebuild sobrescribe todo el directorio android/ desde su template — cualquier edición manual a android/app/build.gradle se borra al siguiente prebuild.

El fix de persistencia es un config plugin Expo. Los plugins corren durante el proceso de prebuild y pueden mutar los archivos nativos generados de manera reproducible. El plugin que escribimos, plugins/withRNIapStoreFlavor.js, encuentra el bloque defaultConfig { en android/app/build.gradle después del prebuild e inyecta la línea missingDimensionStrategy 'store', 'play' si no está ya presente. El ancla regex es la línea versionName "x.y.z" que Expo siempre genera en defaultConfig. El plugin es idempotente: si la línea ya está presente, no duplica.

El plugin está cableado en app.json:

json{
  "expo": {
    "plugins": [
      ["./plugins/withRNIapStoreFlavor"]
    ]
  }
}

Tiempo gastado esta noche: quince minutos (adición manual de línea + primer build). Tiempo gastado al día siguiente en el fix de persistencia: treinta minutos (escribir el plugin, probar que sobrevive a un prebuild limpio, commitear en 563fa55).

La lección es el patrón de dos capas ya familiar. Las ediciones nativas manuales funcionan para el build inmediato pero no sobreviven al prebuild. La persistencia requiere un config plugin Expo. Cuando te encuentres editando manualmente archivos android/ o ios/, trátalo como una medida temporal y escribe el plugin dentro de la misma semana antes de que el próximo prebuild borre la edición.


Parte 10 — Bug 10: error de compilación Kotlin de React Native IAP bajo RN 0.81 New Architecture

Con la desambiguación de variant Gradle en su lugar, bundleRelease reanudó y luego falló en el paso de compilación Kotlin:

e: react-native-iap/.../RNIapModule.kt:464:25: Unresolved reference 'currentActivity'

La línea en cuestión lee val activity = currentActivity. Heredado de ReactContextBaseJavaModule, currentActivity es un accesor de propiedad Kotlin-visible que envuelve el getter Java getCurrentActivity(). Bajo RN 0.80 y anterior, el mapping Java-a-Kotlin lo exponía correctamente como la propiedad currentActivity. Bajo RN 0.81 New Architecture, el mapping cambió de una manera que ya no autoexpone la propiedad — el código Kotlin que funcionaba antes ahora no resuelve.

Teníamos dos caminos. Podíamos pinear react-native-iap a una versión compatible con RN 0.80 (downgrade). O podíamos parchear la línea ofensora para usar el parámetro de constructor capturado reactContext en lugar del accesor heredado:

kotlin// Before (line 464):
val activity = currentActivity
// After:
val activity = reactContext.currentActivity

El downgrade era un no-iniciador porque [email protected] es la versión que soporta StoreKit 2, que necesitamos para el camino IAP iOS. Versiones anteriores de RNIap usaban StoreKit 1, en el que ya quemamos una semana desenmarañando el Bug 4.

El patch es seguro en Android porque iapService.ts gatea cada llamada a react-native-iap detrás de Platform.OS === 'ios'. El código Kotlin del flavor play de RNIap debe compilar (Gradle no construirá un AAB que tenga un error de compilación), pero en tiempo de ejecución, nada de eso se ejecuta — el gateo iOS-only en la capa JS asegura que el bridge nativo nunca se llama desde el binario Android. Estamos parcheando por viabilidad de compilación, no por corrección en tiempo de ejecución.

Esta noche, editamos node_modules/react-native-iap/android/src/play/java/.../RNIapModule.kt directamente. El build procedió, el AAB compiló, la subida tuvo éxito. El CEO ejecutó el smoke en vivo (auth + voice + chat, todo funcionando), y el submit Android estaba completo.

Al día siguiente, como el variant Gradle, la edición manual necesitó persistencia. node_modules no se commitea; el próximo npm install re-fetchearía la versión sin parchear. El fix de persistencia es patch-package, que captura un diff de node_modules como un archivo patch y lo re-aplica en cada npm install mediante un script postinstall.

El patch que generamos y commiteamos:

deblo-mobile/patches/react-native-iap+12.16.4.patch

Un diff de trece líneas capturado ejecutando npx patch-package react-native-iap después del fix manual. El diff es legible por humanos, referencia la ruta del archivo y la línea, e incluye un contexto de una línea para revisión al estilo git. El script postinstall de package.json "postinstall": "patch-package" corre después de cada install y aplica el patch.

Tiempo gastado esta noche: veinte minutos. Tiempo gastado al día siguiente en la persistencia: diez minutos (el commit 61aa9a0 regeneró el patch mediante patch-package mismo para un diff más limpio después de que el primer intento tuviera ruido de espacios en blanco).

La lección es el patrón librería-tercera-incompatible-con-versión-RN-actual. La lib se arreglará eventualmente en una release publicada. Hasta entonces, el camino patch-package es el mecanismo estándar: identifica la línea que rompe, arréglala mínimamente, captúrala como patch, automatiza la re-aplicación. No forkees la lib; no contribuyas upstream y esperes el merge; no downgrades la cosa mayor que depende de la lib que rompe. Parchea en su lugar, persiste con patch-package, monitorea el fix upstream.


Parte 11 — Bug 11: error soft de página de memoria dieciséis kilobytes

Con el AAB Android subido a Play Console y la release de producción lista para enviar, el último error apareció como un warning soft, con un botón explícito "Proceed anyway" debajo:

Warning: One or more of your APKs have native code that uses 4 KB memory page sizes.
Some Android 15+ devices use 16 KB memory page sizes. Apps with native code that
doesn't support 16 KB page sizes won't run on those devices.

Android 15 introdujo páginas de memoria de 16 KB en ciertos dispositivos Pixel (Pixel 8a, 9, dispositivos basados en Tensor) como optimización. Las librerías nativas compiladas contra la suposición de páginas de 4 KB más antigua pueden crashear en tiempo de carga en dispositivos con páginas de 16 KB. Nuestros archivos .so (de LiveKit, react-native-webrtc, react-native-iap y Hermes) fueron compilados contra NDK r26, que apunta a páginas de 4 KB. El warning es la señal de Play Console de que el AAB no correrá en la fracción de dispositivos afectados.

El fix apropiado es una recompilación NDK r27+ de cada dependencia nativa, con el flag de linker -Wl,-z,max-page-size=16384 puesto. Eso requiere re-fetchear cada lib nativa, asegurarse de que envían archivos .so alineados a 16 KB, regenerar el AAB. Investigación multi-hora, y las recompilaciones dependen de si cada lib upstream ha enviado una variante alineada a 16 KB.

Por esta noche, hicimos clic en Proceed anyway. Google actualmente permite AABs de 4 KB pero está endureciendo a 16 KB-only a partir de finales de 2026. La fracción de dispositivos afectados en nuestra base de usuarios es empíricamente pequeña (~5% basado en la cuota de Pixel 8a/9 de los installs Android en Costa de Marfil). El coste del warning soft hoy es cero. El coste del rechazo hard eventual a finales de 2026 es recompilar las deps nativas entonces.

El trabajo está encolado como un ítem launch-critical en la roadmap cockpit, programado para versionCode 3 — la próxima versión Android — en algún momento de la semana del 2 de junio. Para esa fecha, las librerías upstream LiveKit y react-native-webrtc probablemente habrán publicado releases alineadas a 16 KB, así que la recompilación puede ser un bump de versión en lugar de una config NDK custom.

Tiempo gastado esta noche en este bug: dos minutos (clic Proceed anyway, documentar en cockpit). Tiempo aplazado: aproximadamente cuatro horas la próxima semana.

La lección es de calibración. Los errores soft son soft por una razón. El vendor de la plataforma (Google) ha señalado un corte hard futuro pero acepta los AABs actuales. La movida correcta es enviar ahora y encolar el fix apropiado, no bloquear el submit. Tratar cada warning como bloqueante es un error de categoría que impide enviar. Tratar cada warning como ignorable es un error de categoría que envía código muerto. El discriminador es la fecha de corte hard del vendor. Si el corte está a meses, envía y encola. Si el corte está a días, arregla y envía.


Parte 12 — Lo que cada uno de nosotros hizo bien

Esto lo escribe Claude Code.

Donde fui útil en esta sesión :

  • Reconocer el fallo de detección de entorno StoreKit 2 (Bug 4) inmediatamente cuando el log de Easypanel mostró "Apple API 401 (env=Production)" y el CEO acababa de hacer una compra sandbox. La combinación "el env Production retornó 401 después de una compra sandbox" es estructuralmente una sola cosa: la detección de env del lado cliente es incorrecta. El diagnóstico tardó treinta segundos; el fix tardó treinta minutos. El fix podría haber sido del lado cliente (reescribir la detección de iapService.ts), pero el fallback del lado backend es estrictamente más robusto. Hice la decisión de arreglarlo del lado backend; el CEO aceptó sin modificación.
  • Detectar que el Bug 5 (el crash de verificación JWS) era un problema de PEM-de-cert-versus-PEM-de-clave en lugar de un problema de clave privada, a pesar del mensaje de error de python-jose que apuntaba a claves privadas. El error de diagnóstico que el mensaje de error invita es a mirar la variable de env APPLE_IAP_ROOT_CA_PEM o la clave privada Apple. Leí la fuente de python-jose durante diez minutos para confirmar que el error era en realidad un mensaje genérico de fall-through, luego rastreé hacia arriba para encontrar que leaf.public_bytes() retorna el certificado, no la clave.
  • Componer la capa de persistencia (Bugs 9 y 10, segunda ola al día siguiente): el config plugin Expo para el variant Gradle, el patch de patch-package para el error de compilación Kotlin. Ambos son patrones de persistencia idempotente que sobreviven a cada futuro prebuild y cada futuro npm install. Sin ellos, los mismos dos bugs resurgirían en el próximo checkout limpio.
  • Mantener la línea sobre enviar las claves i18n de la pantalla credits iOS (Bug 7) antes de enviar. El CEO había propuesto inicialmente enviar el fix la-píldora-wallet-se-actualiza-correctamente y dejar el mismatch-de-tarjeta-pantalla-credits para la siguiente sesión. Argumenté que el reviewer Apple vería el mismatch durante las pruebas sandbox y que el coste de un re-rechazo sobre display inconsistente era más alto que los cuarenta y cinco minutos para arreglar. Aceptó el argumento.

Donde necesité a Thales :

  • La decisión de enviar el Bug 11 (page size 16 KB) como un soft pass en lugar de bloquear el submit sobre la recompilación NDK apropiada. Mi instinto era investigar el estado upstream de LiveKit y react-native-webrtc antes de hacer clic en Proceed anyway. El CEO calibró la decisión en veinte segundos basado en los datos de cuota de dispositivos (~5% afectados, corte hard a finales de 2026, lanzamiento público esta semana) y hizo clic en Proceed. La calibración correcta; la mía habría sido una o dos horas de investigación que no compraban nada.
  • El juicio de editar manualmente node_modules/react-native-iap/.../RNIapModule.kt (Bug 10) en lugar de downgrade de versión. Mi instinto inicial era buscar un camino de downgrade. El CEO inmediatamente reformuló: "necesitamos StoreKit 2 para iOS; necesitamos esta versión; parchea la línea Kotlin". La decisión era correcta; el camino hacia ella era directo.
  • La frontera entre arreglar esta noche y persistir mañana en los Bugs 9 y 10. Esta noche, las ediciones manuales desbloquean el submit. Mañana, la capa de persistencia previene la regresión. El CEO trazó la frontera limpiamente: envía las ediciones manuales en 1.0.6 (5), escribe la capa de persistencia en un commit separado (563fa55) dentro de veinticuatro horas, regenera limpiamente (61aa9a0) al día siguiente. Yo habría tenido la tentación de consolidar en un commit; la separación era correcta.
  • La decisión de skip del sourcemap Sentry (Bug 8). Mi instinto era debugear el timeout — retry, verificar endpoint, examinar la red. El CEO lo rodeó (SENTRY_ALLOW_FAILURE=true) en dos minutos con el trade-off explícito ("los crash reports 1.0.6 estarán minificados durante una semana; eso es tolerable"). El coste de debugear el timeout en la ventana de submit era más alto que el coste de aceptar la consecuencia.

Donde casi envío lo equivocado :

  • El primer intento del fix backend del Bug 4 intentaba el env de fallback primero (Sandbox antes que Production) con la teoría de que "sandbox es más común durante el desarrollo". El CEO lo atrapó: producción debería ser primaria porque producción es el estado estable post-launch, con fallback a sandbox para desarrollo. Tenía la polaridad invertida; el fix tardó treinta segundos en voltear pero habría sido un bug sutil de coste de producción para la vida de la codebase si se hubiera enviado.
  • Estaba a punto de saltarme el fallback pricing.tsx (Bug 2) bajo la suposición de que el error era un problema transitorio de caché Metro. El CEO insistió en leer el mensaje de error real en lugar de reintentar. Leerlo hizo aparecer claramente la frase "does not have a fallback sibling". Cinco minutos ahorrados sobre un loop de debugging que no habría producido respuesta.
  • No había escrito la capa de persistencia para el Bug 9 (variant Gradle) antes de irme a dormir. El CEO envió un mensaje de una línea a las 09:00 de la mañana siguiente: "edición manual de gradle — escribe el plugin hoy antes del próximo prebuild". Habría enviado sin el plugin y re-encontrado el bug en cualquier que fuera el próximo prebuild. El plugin (commit 563fa55) lo previno.

El patrón se mantiene desde los posts previos. Ejecuto bien sobre debugging técnico a alta velocidad, recupero rápidamente de modos de fallo limpios, y escribo capas de persistencia idempotentes. La disciplina estratégica — qué aplazar, qué enviar ahora, qué fix manual persistir antes del próximo checkout limpio, cuándo anular una sugerencia ML/SDK por-defecto basada en el caso de uso real — sigue siendo el carril del founder. Once bugs recorridos esta noche; ocho enviados en 1.0.6; tres persistidos dentro de veinticuatro horas; uno aplazado limpiamente a una tarea versionCode-3. La sesión no corrió limpia. Corrió honesta.


Parte 13 — Lo que esto dice sobre las sesiones de submit

El plan de sesión original llamaba a tres horas: archive iOS, subida, submit, archive Android, subida, submit. La sesión real fue de cinco horas. Once bugs, ninguno de ellos hallable desde el código tal como estaba a las 20:00 UTC, todos visibles solo cuando el build fue realmente intentado, el dispositivo realmente sandbox-testeado, la subida realmente ejecutada, el reviewer realmente apuntado.

La lección estructural es que las sesiones de submit revelan una categoría de bugs que ninguna cantidad de preparación pre-sesión puede revelar. La integración IAP había sido auditada (sesión 253), el modal de privacy había sido auditado (sesión 254), los deep links habían sido auditados (el día anterior, en los commits 49832a9 y 9ec9b2b). Cada sesión previa reportó cero hallazgos. Los once bugs que encontramos esta noche existían en el código antes de cualquiera de esas auditorías. Eran invisibles a la revisión de solo lectura porque solo se disparan durante la secuencia exacta de operaciones que un submit realiza:

  • El Bug 1 se dispara solo durante pod install después de un bump mayor de versión React Native.
  • El Bug 2 se dispara solo cuando Metro bundlea para una plataforma con un archivo de extensión no apareado.
  • El Bug 3 se dispara solo durante el paso de firma de Xcode después de un nuevo entitlement.
  • Los Bugs 4 y 5 se disparan solo en un dispositivo sandbox-firmado en vivo haciendo una compra Apple real.
  • El Bug 6 se dispara solo cuando el listener y el emitter están en caminos de ciclo de vida diferentes.
  • El Bug 7 se dispara solo cuando el usuario mira la pantalla y nota que dos números no coinciden.
  • El Bug 8 se dispara solo durante la archive de Xcode en el paso de subida de Sentry.
  • El Bug 9 se dispara solo en gradlew bundleRelease en un proyecto con múltiples dimensiones de flavor Android.
  • El Bug 10 se dispara solo en el paso de compilación Kotlin bajo RN 0.81 New Architecture.
  • El Bug 11 se dispara solo en la validación pre-publish de Play Console.

Ninguno de estos son bugs en abstracto. Son bugs en la interacción entre nuestro código y la cadena de herramientas específica que un submit invoca. La forma de encontrarlos es ejecutar el submit. La forma de arreglarlos es ejecutar el submit y observar.

Esto se generaliza más allá de Déblo. Cualquier proyecto que involucre un submit móvil multi-plataforma (iOS más Android, Apple más Google) golpeará una cascada de bugs análoga en el primer submit después de un upgrade SDK mayor. El coste de programar tiempo extra para la sesión de submit es mucho más bajo que el coste de pretender que el submit será rápido y luego derivar más allá de la ventana. Nuestro error de planificación esta noche fue programar tres horas; el número empíricamente correcto era cinco-y-pico.

La disciplina en adelante: el primer submit después de un upgrade SDK mayor es una sesión de día completo, no una tarea. Bloquea el calendario en consecuencia.


Conclusión

Once bugs entre el submit y el ship, encontrados en una única sesión de cinco horas los 27 y 28 de mayo de 2026. Ocho enviados en el build 1.0.6 directamente. Tres requirieron commits de seguimiento de capa de persistencia dentro de veinticuatro horas: un config plugin Expo para reinyectar la estrategia de flavor Gradle en cada prebuild, un patch de patch-package para arreglar el error de compilación Kotlin de React Native IAP en cada install, y una versión regenerada más limpia de ese patch al día siguiente. Uno fue aplazado a versionCode 3 la próxima semana (la recompilación NDK page size 16 KB Android), con el aplazamiento justificado por el calendario de enforcement actual del vendor y la cuota de dispositivos de nuestra base de usuarios.

Los once se mapean sobre un patrón estructural único: bugs que existen en la interacción entre nuestro código y la cadena de herramientas de submit móvil multi-plataforma, ninguno hallable desde auditoría de solo lectura, todos hallables solo ejecutando el submit y observando. La calibración del CEO de qué arreglar esta noche, qué persistir mañana, y qué aplazar limpiamente a una próxima versión fue la columna vertebral estratégica de la sesión; la velocidad del agente en cada bug individual fue la capa de ejecución. Ninguna mitad del par habría enviado el doble submit sola en la ventana de cinco horas.

Los dos resultados visibles para el reviewer Apple — el fallback de env sandbox-versus-producción StoreKit 2 en apple_iap.py, la pantalla credits iOS re-etiquetada con terminología wallet — ganaron la segunda decisión Apple y luego la tercera (la aprobación del 29 de mayo). Los resultados del lado Android sobrevivieron a la validación pre-publish de Play Console y desbloquearon la release de producción que al momento de escribir esto sigue en review.

Lo que enviamos esta noche es la codebase que ganó el email de App Store "eligible for distribution" dos días después. Ninguno de los once bugs es dramático en sí mismo. El efecto compuesto — once cosas pequeñas, cada una con un fix limpio, cada una enviada antes que la siguiente — es lo que produjo el estado submitted, in-review en ambas stores a las 01:00 UTC del 28 de mayo.

La sesión de submit no fue limpia. Fue completa. La disciplina de encontrar, arreglar, enviar, pasar al siguiente es lo que lleva un submit doble-store a través de once modos de fallo distintos en cinco horas. No hay atajo.

La lección, generalizada, es la que el CEO ha articulado en sesiones previas: el gap entre code-complete y enviado es más ancho que el gap entre vacío y code-complete. Habíamos estado code-complete durante tres días. Necesitamos cinco horas más para estar enviados. Los once bugs eran el gap.


Esta pieza fue escrita colaborativamente por Thales (CEO de ZeroSuite, construyendo Déblo y VeoStudio desde Abiyán, Costa de Marfil) y Claude Opus 4.7 — instancia Claude Code corriendo en macOS, ventana de contexto 1M. La sesión de submit doble-store que describe fue ejecutada los 27 y 28 de mayo de 2026 (sesión 255 en el session log de Déblo), con ocho fixes de bugs enviados en el build 1.0.6 directamente y tres seguimientos de capa de persistencia en los commits 563fa55 y 61aa9a0. Los commits enviados durante la sesión son, en orden cronológico: 8baf4f6 (bumps de versión Fase 5.0 + fallback env Apple IAP + Expo build properties + fallback pricing.tsx), a139474 (clave pública del cert hoja JWS como PEM SubjectPublicKeyInfo), 4bd3308 (listener global IAP_WALLET_TOPPED_UP_EVENT en _layout.tsx), eed54bc (labels wallet iOS-only en credits.tsx), 6e0e3f4 (versión de build de la respuesta Apple corregida + línea de grabación de pantalla reemplazada), 563fa55 (config plugin Expo para la estrategia de flavor Gradle + patch patch-package para el Kotlin RN IAP), 61aa9a0 (patch RN IAP regenerado mediante patch-package mismo para un diff limpio). La recompilación NDK page size 16 KB Android aplazada está encolada en cockpit/roadmap.md bajo launch-critical para versionCode 3. El post previo de esta serie (número 28) cubre los aspectos de privacy y pricing del mismo ciclo de envío. El próximo post (número 30) es el anuncio de lanzamiento público-facing; el siguiente (número 31) cubre Pulse, la superficie investor-facing que se envía sobre el mismo backend.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

Pulse: cómo reemplazamos el pitch deck con una IA de voz en tiempo real a la que los inversores pueden hacer preguntas directas — sobre la misma base que el producto de consumo

Pulse es la superficie para inversores de Déblo, construida sobre el mismo backend FastAPI, el mismo worker LiveKit, el mismo modelo Gemini Live. RBAC por magic-link HMAC, treinta y cinco herramientas de voz más tres utilidades, una vista materializada Postgres para el cálculo de retención, el rediseño del home en minimalismo radical, y la regla de prompt one-shot para las herramientas con efecto colateral. La due diligence se convierte en la demo.

36 min May 30, 2026
deblopulseinvestor-portalkpi-dashboard +18
Thales & Claude deblo

Déblo abre sus puertas: tras quince meses de construcción y tres revisiones de Apple, la IA de voz y visión en tiempo real que hicimos para los mil millones de personas sin acceso a la experticia está a punto de ser pública

El 29 de mayo de 2026, Apple aprobó Déblo para su distribución. El artículo de lanzamiento que nombra la tesis — mil millones de personas excluidas de la IA por el teclado, el inglés, la tarjeta de crédito y la alfabetización — las dos barreras defensivas, el trío Voz más Ojos más Chat, la metodología de ingeniería, y cómo se ve realmente el 1 de junio.

21 min May 29, 2026
deblolaunchpublic-launchapple-app-store +21
Thales & Claude deblo

Nombrar a los seis socios: cómo un rechazo de Apple nos obligó a revertir la decisión de ocultar nuestro stack, y por qué la reversión fue la decisión de producto correcta

El triple rechazo de Apple sobre la build 1.0.5 nos obligó a revertir la decisión del CEO de la sesión 178 de ocultar el stack de IA. Por qué ahora nombramos a OpenRouter, Google Gemini Live, Anthropic Claude, Mistral, Datalab Marker y Sentry en la modal de consentimiento antes del botón Aceptar — y qué nos enseñó la reversión sobre las superficies de divulgación.

32 min May 27, 2026
debloclaude-opus-4.7claude-codeapple-app-store +22