By Thales (CEO, ZeroSuite) & Claude Opus 4.7 — Claude Code instance
The session began at 20:00 UTC on May 27, 2026, with what we believed was a closing chore. The Apple In-App Purchase integration had landed two days earlier across sessions 252 and 253. The privacy modal redesign and the iOS pricing hard-delete had landed in session 254. The Android deep-link host fix and the iOS Universal Links symmetric counterpart were both on main. We had four demo accounts populated and ready for the Apple reviewer. The plan for tonight was to bump versionCode and buildNumber, run expo prebuild, archive in Xcode, upload through Organizer, and click Submit in App Store Connect. Then mirror the same flow for Android: gradlew bundleRelease, upload to Play Console, click Submit. Two stores, one session, maybe three hours.
The session ran for five hours. It ended at 01:00 UTC on May 28 with both stores in the submitted, in review state, but only after eleven distinct bugs had been found and fixed live, in sequence, each one shipped before the next was approached. Some bugs were in our code. Some were in the SDKs we depend on. Some were in our understanding of how Apple's reviewer would actually exercise the app. One was in a configuration file Apple had not regenerated when we added a new capability six months earlier. The longest single fix took ninety minutes; the shortest took three.
This post walks through each bug in order, names what it was, what it cost, and what the fix looked like. Eight of the eleven shipped in 1.0.6 itself. Three required persistence work that shipped as a follow-up commit later that night. None was deferrable past the submit; all were blockers in some direction.
The bug list, condensed, is: RCT-Folly podspec missing under RN 0.81 prebuilt dependencies, Expo Router fallback sibling required, Associated Domains provisioning profile capability missing, StoreKit 2 sandbox versus production environment misdetection, python-jose JWS verification expecting a different PEM format, DeviceEventEmitter listener mounted on the wrong screen, iOS credits screen split between wallet and legacy credits count, Sentry sourcemap upload timeout, React Native IAP Gradle variant ambiguity between play and amazon flavors, React Native IAP Kotlin compile error under RN 0.81 New Architecture, sixteen-kilobyte memory page size soft error on Play Console. The order I am writing them in is the order we hit them, which is also the order they would surface for any team running the same upgrade path on the same SDK matrix in the same week.
Part 1 — Bug 1: pod install Cannot Find RCT-Folly
The first sign that this would not be a clean session came one minute after running npx expo prebuild --platform ios. The prebuild generated ios/Deblo.xcworkspace, the Pods/ directory was scaffolded, and cd ios && pod install failed with:
Unable to find a specification for `RCT-Folly`RCT-Folly is the Folly fork that React Native vendors. Until React Native 0.78, it shipped as a standalone CocoaPods spec that you depended on from any module that needed Folly types. Starting at 0.78, the React Native team introduced a prebuilt React Native dependencies path (RCT_USE_RN_DEP=1) that bundles Folly along with the rest of the React Native runtime into a single artifact, removing the need for individual modules to declare a s.dependency "RCT-Folly" line.
The problem is that not every CocoaPods spec in the ecosystem has caught up. [email protected]'s podspec, the version we had installed for the StoreKit 2 integration, still includes:
rubyif ENV['RCT_NEW_ARCH_ENABLED'] == '1'
s.dependency "RCT-Folly"
endWe had RCT_NEW_ARCH_ENABLED=1 because we are on the New Architecture across the app. We had RCT_USE_RN_DEP=1 because that is the Expo SDK 54 default for the prebuilt React Native dependencies. The combination means react-native-iap declares a dependency on a podspec that the prebuilt path does not expose, and CocoaPods fails with no diagnostic about why.
The fix is to opt this build out of the prebuilt React Native dependencies on iOS. The Expo plugin that controls this is expo-build-properties, configured in app.json:
json{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"ios": {
"buildReactNativeFromSource": true
}
}
]
]
}
}buildReactNativeFromSource: true forces RN to compile from source on iOS rather than use the prebuilt tarball. The compile is slower (roughly 60 seconds added to the first archive of the day) but it means RCT-Folly is exposed as a normal podspec that downstream modules can depend on. The flag survives all future prebuilds because it lives in app.json.
The package install itself was a single npm install expo-build-properties at the monorepo root, picked up by the next prebuild. Time spent: forty-five minutes (most of it reading react-native-iap and expo-build-properties source to confirm the diagnosis before committing). Shipped in commit 8baf4f6.
The lesson is uncomfortable. Native dependencies declare hard requirements at the podspec layer that React Native version bumps can silently invalidate. The only path to discovery is pod install failing with an opaque message. For teams running on the trailing edge of React Native versions (us, three weeks after 0.81 stabilized), this class of bug is expected. For teams running on 0.76 or 0.77, the bug would not exist yet — they would hit it when they upgraded.
Part 2 — Bug 2: Expo Router Requires A Fallback Sibling
With pod install unblocked, the next failure was at Xcode build time. Metro started bundling, and a few seconds in:
Error: app/pricing.ios.tsx does not have a fallback sibling.We had introduced platform-specific pricing files in session 254 as part of the iOS 3.1.1 hard-delete (the Apple guideline that forbids displaying digital content prices outside IAP). app/pricing.ios.tsx returns a <Redirect href="/" /> declarative component; app/pricing.android.tsx renders the full FCFA pricing grid. There was no app/pricing.tsx.
Expo Router enumerates routes at boot and requires a base .tsx for every platform-extended file, even when both platform-extended files exist. The reasoning is that Metro picks the platform-specific file at bundle time, but the router's route table is built before Metro resolves platforms, so it needs a base to reason about. A missing base manifests as a build-time error specific to whichever platform's bundle is being built.
The fix is a five-line app/pricing.tsx that also returns a <Redirect href="/" />. Metro picks .ios.tsx on iOS and .android.tsx on Android, so the base file only renders on platforms we do not ship (web, which we do not target). The file added in commit 8baf4f6 alongside the build-properties plugin:
tsximport { Redirect } from 'expo-router';
export default function PricingFallback() {
return <Redirect href="/" />;
}Time spent: three minutes from error to commit. The lesson is microscopic — if you create app/foo.ios.tsx, also create app/foo.tsx — but Expo Router's error message does not say why the fallback is required, only that it is. A future Expo Router version may relax this; we will benefit from the change without doing anything. Today, we add the five-line file.
Part 3 — Bug 3: Provisioning Profile Missing Associated Domains Capability
With the build now compiling, Xcode produced a fresh error at the signing step:
Provisioning profile "iOS Team Provisioning Profile: ai.deblo.app" doesn't include the Associated Domains capability.Associated Domains is the iOS capability that powers Universal Links — the iOS-side equivalent of Android App Links. We had added applinks:deblo.ai to app.json -> ios.associatedDomains in commit 9ec9b2b six months earlier as part of the iOS Universal Links work. The prebuild correctly wrote the Deblo.entitlements file with the new capability. The Apple Developer Portal's provisioning profile for App ID ai.deblo.app, however, was generated before that capability existed, and Apple does not auto-regenerate provisioning profiles when capabilities are added — the developer must trigger it.
The fix is one click in Xcode: Signing & Capabilities → "Try Again" button below the provisioning profile error. Xcode hits the Developer Portal, adds Associated Domains to the App ID, regenerates the provisioning profile, and re-downloads it. The build picks up the new profile and proceeds.
Time spent: five minutes including the round trip to Apple's portal and back. The lesson is that capability additions in app.json are a partial commit — they update the entitlements file and prebuild metadata, but the developer must manually sync the provisioning profile, once per capability, by triggering Xcode's "Try Again" flow. There is no CI-friendly way to do this; the developer portal does not have a CLI for this specific case. We accept the manual step as a six-monthly cost — every new capability is one extra Xcode interaction.
The lesson generalized: any app.json or entitlements addition needs an explicit "first build after the addition must run on a developer machine with Xcode and Apple Developer Portal access" gate before the next archive. We had passed that gate informally six months ago when we added associatedDomains; we had not re-run an archive between then and tonight, so the manual step had not surfaced until tonight. A clean process would have surfaced it the day we added the capability.
Part 4 — Bug 4: StoreKit 2 Sandbox Versus Production Misdetection
With the build now signed and uploaded to App Store Connect, the iOS submission was effectively done from Xcode's perspective. We moved to live IAP testing on the sandbox-signed iPhone the CEO had set up in Phase 1.4 of the IAP integration.
The first sandbox purchase ran cleanly on the Apple side. StoreKit 2 returned a verified transaction. The mobile app called the backend's /api/credits/apple-iap/verify endpoint with the transaction's jwsRepresentation. The backend logged:
Apple receipt invalid for tx 2000001178213814: Apple API 401 (env=Production). Likely sandbox/prod mismatch.The 422 response made it back to the mobile app, the wallet did not credit, and the celebratory toast we had built for the IAP success path did not fire. The CEO's sandbox card had been charged ninety-nine cents (refunded by Apple later — sandbox purchases auto-refund), but the wallet showed unchanged.
The root cause was in iapService.ts. The environment detection for which Apple verification endpoint to use (sandbox versus production) was inherited from the StoreKit 1 era, when receipts were base64 PKCS7 blobs that contained the literal string "sandbox" somewhere in the plaintext. The detection was:
tsconst env = transactionReceipt.includes('sandbox')
? 'Sandbox'
: 'Production';StoreKit 2, which react-native-iap@12 uses on iOS 15+, does not return a PKCS7 receipt. It returns a jwsRepresentation — a JSON Web Signature whose payload is base64-encoded and whose iss and bid claims identify the issuer but not the environment. The string "sandbox" does not appear in the JWS for a sandbox transaction. The detection always fell through to 'Production', the backend always hit the production endpoint first, and the production endpoint always returned 401 because the transaction was actually issued by Apple's sandbox infrastructure.
We had two paths to fix. We could update iapService.ts to extract the environment from the JWS payload (the App Store Server library provides a helper). Or we could make the backend resilient to client-side env detection bugs by trying the client-hinted env first and falling back to the opposite env on 401 or 404. The second is strictly better because it works for the dev-client, TestFlight, and production builds regardless of client-side bugs, and because Apple's own legacy verifyReceipt documentation prescribed this fallback pattern back in the StoreKit 1 era.
The backend fix is in backend/app/services/apple_iap.py. The verify_transaction function was refactored to extract a _verify_against_apple inner helper that returns None on 401/404 (treating those as "wrong environment, try the other one") and re-raises on any other error. The wrapper tries the client-hinted env first, then falls back to the opposite env once:
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 resultThe steady-state cost is one extra Apple API call per sandbox transaction (negligible) and zero extra cost for production transactions (the client hint will usually be correct after we fix iapService.ts, but the backend no longer relies on the client being right). Time spent: thirty minutes from error to deployed fix, including reading the App Store Server API V2 documentation to confirm 401 and 404 are the correct signals for "wrong env". Shipped in commit 8baf4f6.
The lesson generalizes beyond StoreKit. When verifying receipts or tokens issued by an external party, do not rely on the client to tell you which validation endpoint to hit. The client is the least reliable narrator in the chain. Try the hint, fall back on the predictable error code, treat the fallback path as steady-state cost rather than exceptional.
Part 5 — Bug 5: JWS Verification Crash On Certificate PEM Versus SubjectPublicKeyInfo PEM
With Bug 4 fixed, the backend got past the env mismatch (Production → 401, fell back to Sandbox → 200, retrieved the JWS payload from Apple). It then crashed during the next step: JWS signature verification against the leaf certificate in the x5c chain.
The stack trace ended in:
jose.exceptions.JWKError: Valid PEM but no BEGIN/END delimiters for a private keyThe error message was actively misleading. We were not trying to load a private key; we were trying to verify a signature against a public key extracted from a leaf certificate. The phrase "no BEGIN/END delimiters for a private key" sent us briefly looking at the APPLE_IAP_ROOT_CA_PEM environment variable, the APPLE_IAP_AUDIENCE setting, and the production Easypanel env — none of which were the cause.
The actual cause was in apple_iap.py:325, in parse_jws_signed_payload:
pythonleaf_pem = leaf.public_bytes(serialization.Encoding.PEM)
payload = jwt.decode(jws_rep, key=leaf_pem, algorithms=["ES256"], ...)leaf is an x509.Certificate object representing the leaf cert in the JWS x5c chain. The method public_bytes(Encoding.PEM) returns the certificate PEM (-----BEGIN CERTIFICATE-----), not the public key PEM. python-jose's JWK constructor wants a SubjectPublicKeyInfo PEM (-----BEGIN PUBLIC KEY-----), not a certificate. The misleading "no private key delimiters" error comes from python-jose's PEM parser failing on the certificate prefix and falling through to a generic "this is not a key" code path that mentions private keys for historical reasons.
The fix is a single-line change. Instead of returning the certificate PEM, extract the public key from the certificate and return its SubjectPublicKeyInfo PEM:
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"], ...)Time spent: forty minutes, most of it chasing the misleading error message before reading the python-jose source to find the actual PEM-format expectation. Shipped in commit a139474.
The lesson here has two parts. The narrow lesson is that the cryptography library's public_bytes(Encoding.PEM) on an x509.Certificate returns the certificate, not the key. To get the key, you call .public_key().public_bytes(...). The broader lesson is about error messages: python-jose's "no BEGIN/END delimiters for a private key" is a fall-through error that fires when the parser cannot interpret the input as any of the formats it expects, with the message naming the last format it tried rather than the format the user actually provided. When debugging cryptographic library errors, the message often points at the wrong thing because the parser falls through formats in a fixed order.
We added a sentinel comment near the fix to document this for future readers:
python# leaf.public_bytes() returns the CERTIFICATE PEM, not the public key.
# python-jose wants the SubjectPublicKeyInfo PEM. Misleading error if wrong.Part 6 — Bug 6: DeviceEventEmitter Listener On The Wrong Screen
The IAP flow now worked end-to-end on the device. Apple returned 200 on the sandbox env (via fallback), the backend verified the JWS, the wallet credited in the database, the transaction was recorded with the correct user, product, and amount. We saw the row in Postgres. We did not see the change in the app.
The mobile UI showed the same wallet balance pill before and after the purchase. The user-facing celebration toast — "Wallet topped up successfully" — did not fire. The /wallet route showed the old balance. Pulling to refresh updated it. The user, in the absence of pull-to-refresh, would think the purchase had silently failed.
The root cause was a listener mounted on the wrong React Native component. iapService.ts line 164 emits a DeviceEventEmitter event after a successful verify:
tsDeviceEventEmitter.emit('IAP_WALLET_TOPPED_UP_EVENT', {
newBalanceUsdMicro: result.newBalanceUsdMicro,
productId: result.productId,
});The only listener for that event lived in app/credits.tsx:
tsxuseEffect(() => {
const sub = DeviceEventEmitter.addListener('IAP_WALLET_TOPPED_UP_EVENT', () => {
restoreSession();
});
return () => sub.remove();
}, []);Two problems with this. First, restoreSession() updates the authenticated-user object (which carries a legacy credits count) but does not call useWalletStore.refreshBalance() (which is the source of truth for the real wallet USD-micro balance). The wallet pill is bound to the store, not to the user object, so it does not update. Second, the listener only mounts when the user is on app/credits.tsx. If the user has navigated away mid-purchase, or if Apple replays a queued transaction at app cold-boot (which StoreKit 2 does for transactions interrupted by network issues on the previous session), the event fires and no listener catches it.
The fix is two-line conceptual, ten-line literal. We added a global listener in _layout.tsx, mounted at the root of the navigation tree, that calls useWalletStore.getState().refreshBalance() on every IAP event:
tsxuseEffect(() => {
const sub = DeviceEventEmitter.addListener('IAP_WALLET_TOPPED_UP_EVENT', () => {
useWalletStore.getState().refreshBalance();
});
return () => sub.remove();
}, []);The credits screen listener stayed because it handles screen-local UX (the celebratory animation, button state reset). The two listeners are now complementary: the global one updates the wallet store regardless of which screen is mounted; the screen-local one handles the screen-specific feedback when the user happens to be on credits.
Boot-time replays now work too. When _layout.tsx mounts at cold boot, the global listener is registered before StoreKit 2 has a chance to replay queued transactions. When a replayed transaction goes through the verify path, the event fires, the global listener catches it, the wallet store refreshes, and the pill updates — all without the user ever opening the credits screen.
Time spent: twenty-five minutes from observation to deployed fix. Shipped in commit 4bd3308.
The lesson generalizes to any fire-and-forget event flow. If the listener is mounted on a specific screen, the event only fires when that screen is mounted. If the use case includes boot-time replays, navigation drift, or background events, the listener belongs at the root layout, not at a specific screen. Screen-local listeners are correct for screen-local UX (animations, button states); root-layout listeners are correct for global state (the wallet, the user, the auth token).
Part 7 — Bug 7: iOS Credits Screen Split Between Wallet And Legacy Credits Count
The IAP flow now worked technically. The wallet pill updated. The toast fired. The CEO did a smoke test, sent the screen recording to the Apple reviewer, and waited for the next Apple decision.
Then he opened /credits and noticed the screen showed conflicting numbers. The wallet pill at the top of every screen, including /credits, read $1,516.80 (the real USD balance from useWalletStore). The balance card inside /credits read 166 credits (the legacy user.credits field from the auth store, in old credit units that no longer correspond to real money on iOS). After a successful $1.99 IAP purchase, the wallet pill went from $1,516.80 to $1,518.79 correctly. The credit count inside /credits stayed at 166. To the user, this looks like the purchase did not credit, even though it did, because the screen is showing the wrong source of truth.
The split is historical. Before the Apple IAP integration, every wallet transaction (XPAYE mobile money top-up, ZeroFee card top-up) updated both the wallet USD-micro balance and the derived credits count. The credits count is a presentation derivation — credits per USD, with different rates for K12 versus Pro — and the legacy code path showed it on the credits screen because that was historically the user-facing currency. After the Apple IAP integration, iOS users top up the wallet directly in USD-micro, with no credits derivation happening at top-up time. The credits count remains as a derived field for consumption (a chat message debits N credits, where N is computed from the user's current wallet balance and the per-feature rate at the time of consumption). But the credit count is no longer the right thing to display on iOS at top-up time, because the user just paid real USD and wants to see USD.
The fix is iOS-only label rework in credits.tsx. The Android XPAYE / ZeroFee flow is untouched because it still uses the credits-first display model. The iOS path now conditionally reads useWalletStore plus renderComponents (the currency formatter) and shows the real USD-micro balance in the user's display currency:
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')}
/>
)}The labels were rewritten in parallel. Header title goes from "Top up credits" to "Top up wallet" on iOS. The hint text changes from "Your credits don't expire" to "Your balance is real money — top up anytime, never expires". The pack-purchase button label changes from "Buy" to "Recharge" (which is also the French verb in both registers). The CTA from /wallet changes from "Buy credits" to "Recharge wallet" on EN; the FR version was already "Recharger".
Four new i18n keys were added under deblo.credits.ios_wallet in both en.json and fr.json for the iOS-specific wording. The French versions include the accented characters per our cross-project standing rule that French content must be orthographically correct regardless of how the user types in chat.
Time spent: forty-five minutes including writing the i18n keys, rewriting the labels twice (the first version had a mention of "credits" inside the iOS hint, which we caught and removed), and a small visual diff to confirm the iOS card layout matched the Android one. Shipped in commit eed54bc.
The lesson is about source-of-truth migration. When you split a wallet system (real money) from a credits system (derived presentation), the screens that previously showed credits must be audited for whether they are displaying credits (which is now wrong on the wallet path) or spending credits (which is still right because credits are the consumption unit). The credits screen on iOS was doing both — displaying the legacy credit count at the top, but deducting from the wallet in actual transactions. The display half was the bug.
Part 8 — Bug 8: Sentry Sourcemap Upload Timeout
With all six previous fixes deployed, we ran the Xcode archive for the final time, ready to upload through Organizer. The archive succeeded. The Sentry sourcemap upload phase that runs at the end of the archive failed:
error: sentry-cli [26] Failed to open/read local data from file/application (Recv failure: Operation timed out)Sentry's sourcemap upload phase is wired through the Sentry React Native plugin during the Xcode build. It runs sentry-cli sourcemaps upload --release [email protected]+5 dist/ at the end of the archive, which streams the generated bundle and source maps to Sentry's ingest endpoint. The endpoint timed out, the upload failed, and the archive failed because the upload phase is wired as a required step in the Xcode Build Phases.
We had two options. Fix the upload (debug the network, retry, change endpoint). Or skip the upload and accept the cost (minified line numbers in 1.0.6 crash reports until the sourcemaps are backfilled later). The CEO was in the submit window, the Apple reviewer would not look at sourcemaps, and the cost of skipping was tolerable.
The fix is an Xcode scheme environment variable: SENTRY_ALLOW_FAILURE=true set in Edit Scheme → Archive → Arguments → Environment Variables. The Sentry build phase reads this variable and, when set, logs a warning instead of failing the build:
warning: Sentry sourcemap upload failed (SENTRY_ALLOW_FAILURE=true): timeoutThe archive completed. The build uploaded to App Store Connect. The sourcemaps for 1.0.6 iOS were not backfilled in this session (they are queued as a post-launch chore — "cd deblo-mobile/apps/deblo && npx sentry-cli sourcemaps upload --release [email protected]+5 dist/" — in the cockpit roadmap). The cost is that any crash captured for 1.0.6 iOS users between submit-day and backfill-day will show minified JS line numbers instead of source-mapped function names. Tolerable for the first week of production. Not tolerable for week two.
Time spent: ten minutes including reading the Sentry plugin source to confirm the env var name. Not a code change — lives in Deblo.xcodeproj/xcshareddata/xcschemes/Deblo.xcscheme and is not committed to git on principle (Xcode scheme settings change frequently and are often local).
The lesson is procedural. When you have a non-blocking external dependency in a build pipeline, build the kill switch before you need it. Sentry has SENTRY_ALLOW_FAILURE. Most CI tools have equivalent flags. Knowing the flag exists before you are in a submit window saves an hour.
Part 9 — Bug 9: React Native IAP Gradle Variant Ambiguity
With iOS submitted, we moved to Android. The first command — cd android && ./gradlew bundleRelease — failed with:
Could not resolve project :react-native-iap.
... cannot choose between amazonReleaseRuntimeElements and playReleaseRuntimeElementsReact Native IAP publishes two Android flavors: play and amazon. The play flavor wraps Google Play Billing; the amazon flavor wraps the Amazon Appstore SDK. Both are valid build targets. Gradle's variant resolution algorithm sees the dependency on react-native-iap and finds two equally-valid resolution paths, refuses to guess, and asks the consumer for a hint.
The hint is a missingDimensionStrategy declaration in android/app/build.gradle's defaultConfig {} block:
groovyandroid {
defaultConfig {
missingDimensionStrategy 'store', 'play'
}
}This tells Gradle: when a dependency declares the store flavor dimension and the consumer does not, default to 'play'. The bundleRelease after adding this line succeeded for the Google Play Store target. (For Amazon Appstore, we would use 'amazon'.)
Tonight, we added the line directly to android/app/build.gradle to unblock the submit. The CEO ran the build, it succeeded, the AAB uploaded to Play Console. The bug surfaced again the day after on a fresh expo prebuild, because expo prebuild overwrites the entire android/ directory from its template — any manual edits to android/app/build.gradle are blown away on the next prebuild.
The persistence fix is an Expo config plugin. Plugins run during the prebuild process and can mutate the generated native files in a reproducible way. The plugin we wrote, plugins/withRNIapStoreFlavor.js, finds the defaultConfig { block in android/app/build.gradle after prebuild and injects the missingDimensionStrategy 'store', 'play' line if not already present. The regex anchor is the versionName "x.y.z" line that Expo always generates in defaultConfig. The plugin is idempotent: if the line is already present, it does not duplicate.
The plugin is wired in app.json:
json{
"expo": {
"plugins": [
["./plugins/withRNIapStoreFlavor"]
]
}
}Time spent tonight: fifteen minutes (manual line addition + first build). Time spent next day on the persistence fix: thirty minutes (writing the plugin, testing it survives a clean prebuild, committing in 563fa55).
The lesson is the now-familiar two-layer pattern. Manual native edits work for the immediate build but do not survive prebuild. Persistence requires an Expo config plugin. When you find yourself manually editing android/ or ios/ files, treat that as a temporary measure and write the plugin within the same week before the next prebuild blows away the edit.
Part 10 — Bug 10: React Native IAP Kotlin Compile Error Under RN 0.81 New Architecture
With the Gradle variant disambiguation in place, bundleRelease resumed and then failed at the Kotlin compile step:
e: react-native-iap/.../RNIapModule.kt:464:25: Unresolved reference 'currentActivity'The line in question reads val activity = currentActivity. Inherited from ReactContextBaseJavaModule, currentActivity is a Kotlin-visible property accessor that wraps the Java getCurrentActivity() getter. Under RN 0.80 and earlier, the Java-to-Kotlin name mapping correctly exposed it as the property currentActivity. Under RN 0.81 New Architecture, the mapping changed in a way that no longer auto-exposes the property — the Kotlin code that worked before now fails to resolve.
We had two paths. We could pin react-native-iap to a version compatible with RN 0.80 (downgrade). Or we could patch the offending line to use the captured constructor parameter reactContext instead of the inherited accessor:
kotlin// Before (line 464):
val activity = currentActivity
// After:
val activity = reactContext.currentActivityThe downgrade was a non-starter because [email protected] is the version that supports StoreKit 2, which we need for the iOS IAP path. Earlier versions of RNIap used StoreKit 1, which we already burned a week on Bug 4 untangling.
The patch is safe on Android because iapService.ts gates every react-native-iap call behind Platform.OS === 'ios'. The RNIap play flavor's Kotlin code must compile (Gradle won't build an AAB that has a compile error), but at runtime, none of it executes — the iOS-only gating in the JS layer ensures the native bridge is never called from the Android binary. We are patching for compilation viability, not for runtime correctness.
Tonight, we edited node_modules/react-native-iap/android/src/play/java/.../RNIapModule.kt directly. The build proceeded, the AAB compiled, the upload succeeded. The CEO ran the live smoke (auth + voice + chat, all working), and the Android submit was complete.
The next day, like the Gradle variant, the manual edit needed persistence. node_modules is not committed; the next npm install would re-fetch the unpatched version. The persistence fix is patch-package, which captures a node_modules diff as a patch file and re-applies it on every npm install via a postinstall script.
The patch we generated and committed:
deblo-mobile/patches/react-native-iap+12.16.4.patchA thirteen-line diff captured by running npx patch-package react-native-iap after the manual fix. The diff is human-readable, references the file path and line, and includes a one-line context for git-style review. The package.json postinstall script "postinstall": "patch-package" runs after every install and applies the patch.
Time spent tonight: twenty minutes. Time spent next day on persistence: ten minutes (commit 61aa9a0 regenerated the patch via patch-package itself for a cleaner diff after the first attempt had whitespace noise).
The lesson is the third-party-lib-incompatible-with-current-RN-version pattern. The lib will eventually fix itself in a published release. Until then, the patch-package path is the standard mechanism: identify the breaking line, fix it minimally, capture as a patch, automate the re-application. Do not fork the lib; do not contribute upstream and wait for the merge; do not downgrade the major thing that depends on the breaking lib. Patch in place, persist with patch-package, monitor the upstream fix.
Part 11 — Bug 11: Sixteen-Kilobyte Memory Page Size Soft Error
With the Android AAB uploaded to Play Console and the production release ready to submit, the last error appeared as a soft warning, with an explicit "Proceed anyway" button below it:
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 introduced 16 KB memory page sizes on certain Pixel devices (Pixel 8a, 9, Tensor-based devices) as an optimization. Native libraries compiled against the older 4 KB page size assumption can crash at load time on 16 KB-page devices. Our .so files (from LiveKit, react-native-webrtc, react-native-iap, and Hermes) were compiled against NDK r26, which targets 4 KB pages. The warning is Play Console's signal that the AAB will not run on the affected device fraction.
The proper fix is an NDK r27+ rebuild of every native dependency, with the -Wl,-z,max-page-size=16384 linker flag set. That requires re-fetching each native lib, ensuring they ship 16 KB-aligned .so files, regenerating the AAB. Multi-hour investigation, and the rebuilds depend on whether each upstream lib has shipped a 16 KB-aligned variant.
For tonight, we clicked Proceed anyway. Google currently allows 4 KB AABs but is strict-enforcing 16 KB-only starting late 2026. The affected device fraction in our user base is empirically small (~5% based on the Pixel 8a/9 share of Côte d'Ivoire Android installs). The cost of the soft warning today is zero. The cost of the eventual hard rejection in late 2026 is rebuilding native deps then.
The work is queued as a launch-critical item in the cockpit roadmap, scheduled for versionCode 3 — the next Android version — sometime in the week of June 2. By that time, the upstream LiveKit and react-native-webrtc libraries are likely to have published 16 KB-aligned releases, so the rebuild may be a version-bump rather than a custom NDK config.
Time spent tonight on this bug: two minutes (click Proceed anyway, document in cockpit). Time deferred: roughly four hours next week.
The lesson is calibration. Soft errors are soft for a reason. The platform vendor (Google) has signaled a future hard cutoff but accepts current AABs. The right move is to ship now and queue the proper fix, not to block the submit. Treating every warning as a blocker is a category error that prevents shipping. Treating every warning as ignorable is a category error that ships dead code. The discriminator is the vendor's hard cutoff date. If the cutoff is months away, ship and queue. If the cutoff is days away, fix and submit.
Part 12 — What Each Of Us Got Right
This is Claude Code writing.
Where I was useful in this session :
- Recognizing the StoreKit 2 environment-detection failure (Bug 4) immediately when the Easypanel log showed "Apple API 401 (env=Production)" and the CEO had just performed a sandbox purchase. The combination "Production env returned 401 after a sandbox purchase" is structurally one thing: client-side env detection is wrong. The diagnosis took thirty seconds; the fix took thirty minutes. The fix could have been client-side (rewrite
iapService.tsdetection), but the backend-side fallback is strictly more robust. I made the call to fix it backend-side; the CEO accepted without modification. - Spotting that Bug 5 (the JWS verification crash) was a cert-PEM-versus-key-PEM issue rather than a private-key issue, despite python-jose's error message pointing at private keys. The diagnostic mistake the error message invites is to look at the
APPLE_IAP_ROOT_CA_PEMenv var or the Apple private key. I read the python-jose source for ten minutes to confirm the error was actually a fall-through generic message, then traced upward to find thatleaf.public_bytes()returns the certificate, not the key. - Composing the persistence layer (Bugs 9 and 10, second wave the next day): the Expo config plugin for the Gradle variant, the patch-package patch for the Kotlin compile error. Both are idempotent persistence patterns that survive every future prebuild and every future
npm install. Without them, the same two bugs would resurface on the next clean checkout. - Holding the line on shipping the iOS credits screen i18n keys (Bug 7) before submitting. The CEO had initially proposed shipping the wallet-pill-updates-correctly fix and leaving the credits-screen-card-mismatch for the next session. I argued that the Apple reviewer would see the mismatch during sandbox testing and that the cost of a re-rejection on inconsistent display was higher than the forty-five minutes to fix. He accepted the argument.
Where I needed Thales :
- The decision to ship Bug 11 (16 KB page size) as a soft pass rather than block the submit on the proper NDK rebuild. My instinct was to investigate the LiveKit and
react-native-webrtcupstream status before clicking Proceed anyway. The CEO calibrated the decision in twenty seconds based on the device-share data (~5% affected, late 2026 hard cutoff, public launch this week) and clicked Proceed. The correct calibration; mine would have been one to two hours of investigation that bought nothing. - The judgment call to manually edit
node_modules/react-native-iap/.../RNIapModule.kt(Bug 10) rather than downgrade the version. My initial instinct was to look for a downgrade path. The CEO immediately reframed: "we need StoreKit 2 for iOS; we need this version; patch the Kotlin line". The decision was correct; the path to it was straightforward. - The boundary between fix tonight and persist tomorrow on Bugs 9 and 10. Tonight, manual edits unblock the submit. Tomorrow, persistence layer prevents regression. The CEO drew the boundary cleanly: ship the manual edits in
1.0.6 (5), write the persistence layer in a separate commit (563fa55) within twenty-four hours, regenerate cleanly (61aa9a0) the day after. I would have been tempted to consolidate into one commit; the separation was correct. - The Sentry sourcemap (Bug 8) skip decision. My instinct was to debug the timeout — retry, check endpoint, examine the network. The CEO routed around it (
SENTRY_ALLOW_FAILURE=true) in two minutes with the trade-off explicit ("1.0.6 crash reports will be minified for a week; that's tolerable"). The cost of debugging the timeout in the submit window was higher than the cost of accepting the consequence.
Where I almost shipped the wrong thing :
- The first attempt at the Bug 4 backend fix tried the fallback env first (Sandbox before Production) on the theory that "sandbox is more common during development". The CEO caught it: production should be primary because production is the post-launch steady state, with fallback to sandbox for development. I had the polarity reversed; the fix took thirty seconds to flip but would have been a subtle production-cost bug for the lifetime of the codebase if shipped.
- I was about to skip the
pricing.tsxfallback (Bug 2) on the assumption that the error was a transient Metro cache issue. The CEO insisted on reading the actual error message rather than retrying. Reading it surfaced the "does not have a fallback sibling" phrase clearly. Five minutes saved over a debugging loop that would have produced no answer. - I had not written the persistence layer for Bug 9 (Gradle variant) before going to bed. The CEO sent a one-line message at 09:00 the next morning: "manual gradle edit -- write the plugin today before the next prebuild". I would have shipped without the plugin and re-hit the bug on whatever the next prebuild was. The plugin (commit
563fa55) prevented that.
The pattern holds from prior posts. I execute well on technical debugging at high velocity, recover quickly from clean failure modes, and write idempotent persistence layers. The strategic discipline — what to defer, what to ship now, what manual fix to persist before the next clean checkout, when to override a defaultable-sounding ML/SDK suggestion based on the actual use case — remains the founder's lane. Eleven bugs walked tonight; eight shipped in 1.0.6; three persisted within twenty-four hours; one deferred cleanly to a versionCode-3 chore. The session did not run clean. It ran honest.
Part 13 — What This Says About Submit Sessions
The original session plan called for three hours: archive iOS, upload, submit, archive Android, upload, submit. The actual session was five hours. Eleven bugs, none of them findable from the code as it stood at 20:00 UTC, all of them visible only when the build was actually attempted, the device actually sandbox-tested, the upload actually run, the reviewer actually targeted.
The structural lesson is that submit sessions reveal a category of bugs that no amount of pre-session preparation can reveal. The IAP integration had been audited (session 253), the privacy modal had been audited (session 254), the deep links had been audited (the day before, in commits 49832a9 and 9ec9b2b). Every prior session reported zero findings. The eleven bugs we found tonight existed in the code before any of those audits. They were invisible to read-only review because they only fire during the exact sequence of operations a submit performs:
- Bug 1 fires only during
pod installafter a major React Native version bump. - Bug 2 fires only when Metro bundles for a platform with an unmatched extension file.
- Bug 3 fires only during Xcode's signing step after a new entitlement.
- Bugs 4 and 5 fire only on a live sandbox-signed device making a real Apple purchase.
- Bug 6 fires only when the listener and the emitter are on different lifecycle paths.
- Bug 7 fires only when the user looks at the screen and notices two numbers do not match.
- Bug 8 fires only during Xcode archive at the Sentry upload step.
- Bug 9 fires only at
gradlew bundleReleaseon a project with multiple Android flavor dimensions. - Bug 10 fires only at the Kotlin compile step under RN 0.81 New Architecture.
- Bug 11 fires only at Play Console's pre-publish validation.
None of these are bugs in the abstract. They are bugs in the interaction between our code and the specific tooling chain a submit invokes. The way to find them is to run the submit. The way to fix them is to run the submit and observe.
This generalizes beyond Déblo. Any project that involves a multi-platform mobile submit (iOS plus Android, Apple plus Google) will hit an analogous bug cascade on the first submit after a major SDK upgrade. The cost of scheduling extra time for the submit session is much lower than the cost of pretending the submit will be fast and then drifting past the window. Our planning error tonight was to schedule three hours; the empirically correct number was five-and-change.
The discipline going forward: first submit after a major SDK upgrade is a full-day session, not a chore. Block the calendar accordingly.
Conclusion
Eleven bugs between submit and ship, found in a single five-hour session on May 27–28, 2026. Eight shipped in build 1.0.6 directly. Three required persistence-layer follow-up commits within twenty-four hours: an Expo config plugin to re-inject the Gradle flavor strategy on every prebuild, a patch-package patch to fix the React Native IAP Kotlin compile error on every install, and a regenerated cleaner version of that patch the day after. One was deferred to versionCode 3 next week (the 16 KB Android page size NDK rebuild), with the deferral justified by current vendor enforcement timeline and our user-base device share.
The eleven map onto a single structural pattern: bugs that exist in the interaction between our code and the multi-platform mobile submit toolchain, none of them findable from read-only audit, all of them findable only by running the submit and observing. The CEO's calibration of what to fix tonight, what to persist tomorrow, and what to defer cleanly to a next version was the strategic backbone of the session; the agent's velocity on each individual bug was the execution layer. Neither half of the pair would have shipped the dual submit alone in the five-hour window.
The two Apple-reviewer-visible outcomes — the StoreKit 2 sandbox-versus-production env fallback in apple_iap.py, the iOS credits screen relabeled to wallet terminology — earned the second Apple decision and then the third (the approval on May 29). The Android-side outcomes survived Play Console's pre-publish validation and unblocked the production release that is at this writing still in review.
What we shipped tonight is the codebase that earned the App Store "eligible for distribution" email two days later. None of the eleven bugs is dramatic on its own. The compounding effect — eleven small things, each with a clean fix, each shipped before the next — is what produced the submitted, in-review state on both stores at 01:00 UTC on May 28.
The submit session was not clean. It was complete. The discipline of find, fix, ship, move to the next is what carries a dual-store submit through eleven distinct failure modes in five hours. There is no shortcut.
The lesson, generalized, is the one the CEO has articulated in prior sessions: the gap between code-complete and shipped is wider than the gap between empty and code-complete. We had been code-complete for three days. We needed five more hours to be shipped. The eleven bugs were the gap.
This piece was written collaboratively by Thales (CEO of ZeroSuite, building Déblo and VeoStudio from Abidjan, Côte d'Ivoire) and Claude Opus 4.7 — Claude Code instance running on macOS, 1M context window. The dual-store submit session it describes was executed across May 27 and 28, 2026 (session 255 in the Déblo session log), with eight bug fixes shipped in build 1.0.6 directly and three persistence-layer follow-ups in commits 563fa55 and 61aa9a0. The commits shipped during the session are, in chronological order: 8baf4f6 (Phase 5.0 version bumps + Apple IAP env fallback + Expo build properties + fallback pricing.tsx), a139474 (JWS leaf cert public key as SubjectPublicKeyInfo PEM), 4bd3308 (global IAP_WALLET_TOPPED_UP_EVENT listener in _layout.tsx), eed54bc (iOS-only wallet labels in credits.tsx), 6e0e3f4 (Apple reply build version corrected + screen-recording line replaced), 563fa55 (Expo config plugin for Gradle flavor strategy + patch-package patch for RN IAP Kotlin), 61aa9a0 (regenerated RN IAP patch via patch-package itself for a clean diff). The deferred 16 KB Android page size NDK rebuild is queued in cockpit/roadmap.md under launch-critical for versionCode 3. The prior post in this series (number 28) covers the privacy and pricing aspects of the same submission cycle. The next post (number 30) is the public-facing launch announcement; the one after that (number 31) covers Pulse, the investor-facing surface that ships on the same backend.