<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ibukun Demehin</title>
    <description>The latest articles on DEV Community by Ibukun Demehin (@hokagedemehin).</description>
    <link>https://dev.to/hokagedemehin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F464324%2F53d2e251-ca8a-429c-a839-1332cb4f813e.jpeg</url>
      <title>DEV Community: Ibukun Demehin</title>
      <link>https://dev.to/hokagedemehin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hokagedemehin"/>
    <language>en</language>
    <item>
      <title>Polish pass #3: store-release edges and platform traps</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:58:02 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/polish-pass-3-store-release-edges-and-platform-traps-26if</link>
      <guid>https://dev.to/hokagedemehin/polish-pass-3-store-release-edges-and-platform-traps-26if</guid>
      <description>&lt;p&gt;Third polish pass, and this one has a theme without trying: &lt;strong&gt;every item is a trap that only appears at the edges&lt;/strong&gt; — on Android rather than iOS, on a real device rather than the simulator, at cold start rather than warm, in a store listing rather than the app. The weeks around a launch are where those edges get found, because that's when strangers start holding the thing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Android's native alert renders &lt;strong&gt;three buttons and silently drops the fourth&lt;/strong&gt; (Cancel vanished; use a sheet, act on dismiss). A single bottom-sheet prop, &lt;code&gt;adjustResize&lt;/code&gt;, makes the sheet trust a window resize that &lt;strong&gt;never happens under edge-to-edge&lt;/strong&gt; — omit it. The web dev server started answering &lt;strong&gt;HTTP 431&lt;/strong&gt; to everything because Cognito tokens in cookies grew past Node's header limit. A transient session-refresh failure was treated as "signed out" while the tokens stayed — users landed on sign-in and were told they were already signed in. Scanned items now carry &lt;strong&gt;brand and pack size&lt;/strong&gt; (with the pack unit deliberately &lt;em&gt;not&lt;/em&gt; stored as the item unit). Plus: the iOS back button that said "index", per-document legal dates, and "CannyCart" becoming "Canny Cart" everywhere — because the store cross-checks your listing against your deletion page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 39 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Android's fourth button doesn't exist
&lt;/h2&gt;

&lt;p&gt;The profile-photo sheet offered four options once a photo was set: camera, library, remove, cancel. On Android, &lt;code&gt;Alert.alert&lt;/code&gt; has &lt;strong&gt;three button slots and silently discards the rest&lt;/strong&gt; — and the one it discarded was &lt;em&gt;Cancel&lt;/em&gt;, leaving a dialog with no way out.&lt;/p&gt;

&lt;p&gt;Replaced with a proper bottom sheet, which has three escape routes by nature (Cancel, the backdrop, hardware back). One subtle rule inside it: &lt;strong&gt;actions run on dismiss, not on press.&lt;/strong&gt; The image picker is itself a modal, and opening a modal over a closing sheet races on both platforms — sometimes the picker appears, sometimes it's swallowed. Let the sheet finish closing, &lt;em&gt;then&lt;/em&gt; act on what was chosen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one prop that hides the keyboard trap
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/photograph-a-paper-list-the-third-input-modality-lop"&gt;Part 20&lt;/a&gt; explained why React Native's core keyboard-avoiding view measures zero under Android's mandatory edge-to-edge: the window doesn't resize, it receives insets. The bottom-sheet library has the same trap wearing a different hat. Pass &lt;code&gt;android_keyboardInputMode="adjustResize"&lt;/code&gt; and the sheet &lt;strong&gt;skips its own keyboard lift and zeroes its scroll inset&lt;/strong&gt;, trusting a window resize that never comes — so the keyboard simply covers the sheet's input. &lt;em&gt;Omit the prop&lt;/em&gt; and the library's own &lt;code&gt;keyboardBehavior&lt;/code&gt; does the lifting correctly.&lt;/p&gt;

&lt;p&gt;The tell that finally pointed at it: two sheets had always worked (the amount and list sheets) and two never had (the review sheets). The difference was that one prop, copied from a snippet that predated edge-to-edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTP 431 from cookies
&lt;/h2&gt;

&lt;p&gt;The web dev server began answering &lt;strong&gt;431 Request Header Fields Too Large&lt;/strong&gt; to &lt;em&gt;everything&lt;/em&gt;. Cause: the auth library was configured for server-side rendering, which stores Cognito tokens in &lt;strong&gt;cookies&lt;/strong&gt;. Localhost cookies are shared across every port; refresh tokens run 4–6 KB each; a few accumulated sessions from different local apps pushed request headers past Node's 16 KB limit.&lt;/p&gt;

&lt;p&gt;The fix was to notice the flag bought nothing — every guard in the console is client-side (&lt;a href="https://dev.to/hokagedemehin/the-admin-console-a-web-app-on-a-mobile-apps-cognito-pool-3ka"&gt;Part 37&lt;/a&gt;), nothing does server-side auth — so tokens went back to &lt;code&gt;localStorage&lt;/code&gt; and an unused adapter dependency left with the flag. One-time cost: cookie-stored sessions aren't readable from local storage, so signed-in admins re-authenticated once. A configuration that "enables" something you don't use is a liability with a delay on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "There is already a signed in user"
&lt;/h2&gt;

&lt;p&gt;A cold-start bug from the v1.0.3 release: the auth gate treated &lt;em&gt;any&lt;/em&gt; error from fetching the session as "signed out". But the auth library &lt;strong&gt;keeps the stored tokens on a transient refresh failure&lt;/strong&gt; — no signal at cold start, a brief identity-provider hiccup — and only clears them on real auth errors. So users with perfectly valid sessions landed on the sign-in screen, entered their password, and were refused with &lt;em&gt;"There is already a signed in user."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two fixes. The gate now &lt;strong&gt;falls back to the stored id token&lt;/strong&gt; when verification throws — a transient failure is not a sign-out. And the login flow honours an existing session for the same email (or signs the old account out first for a different one) instead of surfacing the library's message. The lesson is about error taxonomy: &lt;em&gt;couldn't verify&lt;/em&gt; and &lt;em&gt;not signed in&lt;/em&gt; are different states, and collapsing them punishes exactly the users with the worst signal.&lt;/p&gt;

&lt;p&gt;A sibling bug shipped in the same release: announcement dismissals lived in a per-user key that sign-out &lt;strong&gt;wiped&lt;/strong&gt;, so signing back in replayed every takeover, dialog and strip you'd already seen. They're now device-scoped and keyed by user id — the same account on the same phone stays dismissed; a different account gets its own entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Brand and pack size, and the unit that isn't
&lt;/h2&gt;

&lt;p&gt;Scanned products now carry &lt;strong&gt;brand&lt;/strong&gt; and &lt;strong&gt;pack size&lt;/strong&gt; as first-class fields — and the display name folds the brand in at staging time: "Still Water" doesn't tell you which one to pick up, and the price-history key is the &lt;em&gt;name&lt;/em&gt;, so a stripped brand would merge two different waters into one swinging price. (Guarded against providers that already include it — no "Buxton Buxton Still Water".)&lt;/p&gt;

&lt;p&gt;One deliberately &lt;em&gt;not&lt;/em&gt;-done thing: the pack unit is &lt;strong&gt;not stored as the item's unit&lt;/strong&gt;. Quantity means "how many to buy", so &lt;code&gt;"415 g"&lt;/code&gt; plus quantity 2 would render as "2 g". The pack size lives in its own field and the row reads "× 2". A unit that means two different things is worse than no unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four small ones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The iOS back button said "index".&lt;/strong&gt; The native stack labels the back button with the &lt;em&gt;source route's name&lt;/em&gt; when that screen sets no title, and every push from a tab's index screen rendered "‹ index". A minimal back-button display mode on all three header-bearing stacks leaves just the chevron.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-document legal dates.&lt;/strong&gt; Terms and privacy previously shared one updated-date, so amending either restamped both — a false claim about the other. Now two constants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Canny Cart", with a space.&lt;/strong&gt; The home-screen label said "Canny Cart"; every other surface said "CannyCart". The store title would be the two-word form, and Google Play &lt;strong&gt;cross-checks the listing against the deletion page&lt;/strong&gt; — so legal docs (both renderers), permission strings, web metadata and wordmark all now agree. Identifiers — bundle ids, scheme, table prefixes — untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sandbox prints its env vars.&lt;/strong&gt; A one-line startup log listing which environment variables the sandbox script loaded, because "is the key set?" was the first question in every AI-feature bug report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count the buttons on Android.&lt;/strong&gt; Three is the limit; a sheet has infinite escape routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old snippets carry old assumptions.&lt;/strong&gt; One prop from the pre-edge-to-edge era hid the keyboard on half the sheets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unused capabilities aren't free&lt;/strong&gt; — cookie storage nobody read produced a 431 nobody expected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Couldn't verify" is not "signed out."&lt;/strong&gt; Transient failures deserve the stored token, not the login screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A field means one thing.&lt;/strong&gt; Pack size and purchase quantity don't share a unit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stores read your app more carefully than your users do&lt;/strong&gt; — even the space in the name.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The series, at 39
&lt;/h2&gt;

&lt;p&gt;This closes the fourth batch: the money arc, the vision features, the release, the account move, deletion, languages, the admin console. The next posts will come from whatever ships next — the plan is a living document, and the app keeps outrunning it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the edge your app only breaks on — the platform, the device, the cold start — and when did a real user find it before you did?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>debugging</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>MUI vs Tailwind: the cascade-layers war story</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:56:10 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/mui-vs-tailwind-the-cascade-layers-war-story-pm2</link>
      <guid>https://dev.to/hokagedemehin/mui-vs-tailwind-the-cascade-layers-war-story-pm2</guid>
      <description>&lt;p&gt;The admin console's every input lost its outline. Every button flattened to plain text. A &lt;code&gt;&amp;lt;Select&amp;gt;&lt;/code&gt; printed its raw value on top of its own label. Nothing threw. The server-rendered pages looked perfect — the breakage lived only on the pages behind the login, which is exactly where nobody looks until they're using them.&lt;/p&gt;

&lt;p&gt;Two CSS systems share the web app: &lt;strong&gt;MUI&lt;/strong&gt; (component styles injected at runtime by Emotion) and &lt;strong&gt;Tailwind v4&lt;/strong&gt; (utility classes, plus its "preflight" reset). Their coexistence is a solved problem — MUI documents it — and the solution is CSS cascade layers. This is the story of how a working layer setup got broken twice, each time by a single plausible-looking line, and what the cascade actually does when you get the order wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — MUI's official Tailwind v4 integration puts every Emotion style into a &lt;code&gt;mui&lt;/code&gt; cascade layer; the global stylesheet declares the order &lt;code&gt;theme, base, mui, components, utilities&lt;/code&gt;, so Tailwind utilities (later layers) reliably beat MUI. Two ways to break it, both shipped once: &lt;strong&gt;&lt;code&gt;StyledEngineProvider injectFirst&lt;/code&gt;&lt;/strong&gt; shadows the App Router's cache with an &lt;em&gt;unlayered&lt;/em&gt; one, and Tailwind silently stops winning (a &lt;code&gt;md:hidden&lt;/code&gt; that never hides). &lt;strong&gt;&lt;code&gt;prepend: true&lt;/code&gt;&lt;/strong&gt; on the cache provider inserts Emotion's &lt;code&gt;@layer mui {}&lt;/code&gt; at the front of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; — and since a layer's priority is fixed by where it's &lt;em&gt;first declared&lt;/em&gt;, &lt;code&gt;mui&lt;/code&gt; becomes the &lt;strong&gt;lowest&lt;/strong&gt; layer and Tailwind's preflight outranks every component style: &lt;code&gt;border: 0 solid&lt;/code&gt; erases input outlines, &lt;code&gt;background-color: transparent&lt;/code&gt; flattens buttons, &lt;code&gt;opacity: 1&lt;/code&gt; un-hides the inputs MUI keeps invisible. It hides because only &lt;em&gt;client-inserted&lt;/em&gt; styles are affected — SSR markup looks fine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 38 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the coexistence is supposed to work
&lt;/h2&gt;

&lt;p&gt;CSS cascade layers (&lt;code&gt;@layer&lt;/code&gt;) let you declare that entire groups of rules rank &lt;em&gt;below&lt;/em&gt; other groups, regardless of specificity or source order within them. Tailwind v4 is built on them: its reset lives in a &lt;code&gt;base&lt;/code&gt; layer, utilities in &lt;code&gt;utilities&lt;/code&gt;. MUI's integration guide adds one option to the App Router cache provider — &lt;code&gt;enableCssLayer: true&lt;/code&gt; — which wraps every Emotion-injected style in a &lt;code&gt;mui&lt;/code&gt; layer. The global stylesheet then declares the order once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read left to right, lowest to highest. Tailwind's &lt;code&gt;base&lt;/code&gt; reset ranks &lt;em&gt;below&lt;/em&gt; MUI's components (so the reset can't strip a button), and &lt;code&gt;utilities&lt;/code&gt; ranks &lt;em&gt;above&lt;/em&gt; them (so a &lt;code&gt;hidden&lt;/code&gt; class on an MUI component actually hides it). One line of config, one line of CSS, and two styling systems stop fighting. That was the working state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage #1: &lt;code&gt;StyledEngineProvider injectFirst&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The first plausible line came from older MUI advice about making other CSS beat MUI: wrap the tree in &lt;code&gt;&amp;lt;StyledEngineProvider injectFirst&amp;gt;&lt;/code&gt;. In the App Router it did something subtle — it created a &lt;em&gt;second&lt;/em&gt; Emotion cache, &lt;strong&gt;without the layer option&lt;/strong&gt;, that shadowed the layered one from the cache provider. MUI styles went back to being unlayered, which means they participated in the normal cascade as ordinary high-specificity rules… and Tailwind's layered utilities, being in a layer, ranked &lt;em&gt;below&lt;/em&gt; every unlayered rule.&lt;/p&gt;

&lt;p&gt;The symptom was a single &lt;code&gt;md:hidden&lt;/code&gt; on an MUI icon button that never hid. No error, no warning, just a class that did nothing. The fix was deletion: the cache provider with &lt;code&gt;enableCssLayer&lt;/code&gt; is the integration; anything that constructs another cache undoes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breakage #2: &lt;code&gt;prepend: true&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The second line was more plausible still. &lt;code&gt;prepend: true&lt;/code&gt; on the cache provider makes Emotion insert its styles at the &lt;em&gt;front&lt;/em&gt; of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; instead of the end — commonly recommended so "other CSS can override MUI." Reasonable in an unlayered world. Catastrophic in a layered one, for a reason that took a while to see:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cascade layer's priority is fixed by where it's &lt;em&gt;first declared&lt;/em&gt; in document order.&lt;/strong&gt; The global stylesheet declares &lt;code&gt;theme, base, mui, components, utilities&lt;/code&gt;. But with &lt;code&gt;prepend: true&lt;/code&gt;, Emotion's &lt;code&gt;@layer mui { … }&lt;/code&gt; block landed in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; that stylesheet. So the browser met &lt;code&gt;mui&lt;/code&gt; first — making it the &lt;strong&gt;lowest&lt;/strong&gt; layer of all — and only then read the ordering declaration, which can't demote layers it's already ranked. Tailwind's &lt;code&gt;base&lt;/code&gt; preflight now outranked every MUI component style.&lt;/p&gt;

&lt;p&gt;Three preflight rules did the visible damage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;border: 0 solid&lt;/code&gt; — &lt;strong&gt;every input outline erased&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;background-color: transparent&lt;/code&gt; — &lt;strong&gt;every button flattened&lt;/strong&gt; to plain text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;opacity: 1&lt;/code&gt; — &lt;strong&gt;the inputs MUI keeps invisible got un-hidden&lt;/strong&gt;, so a &lt;code&gt;&amp;lt;Select&amp;gt;&lt;/code&gt; printed its raw value on top of its label.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why it hid for so long
&lt;/h2&gt;

&lt;p&gt;The cruel part: &lt;strong&gt;only client-inserted styles are affected.&lt;/strong&gt; Server-rendered pages carry just the baseline global styles in their markup, and those looked correct throughout. The component styles that break are inserted by Emotion &lt;em&gt;after hydration&lt;/em&gt; — which means everything behind the auth guard (the entire admin console, client-rendered by construction) was broken, while the public landing page and legal pages that everyone checks were fine. The bug was invisible on every page you'd screenshot and total on every page you'd use.&lt;/p&gt;

&lt;p&gt;The fix, again, was deletion — &lt;code&gt;enableCssLayer: true&lt;/code&gt; alone is what the integration guide specifies, and &lt;em&gt;nothing else&lt;/em&gt;. The repo's own instructions now carry both breakages as named rules, because each looked like a fix at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model that would have prevented both
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layers are ranked by first declaration, not by the ordering statement alone.&lt;/strong&gt; The &lt;code&gt;@layer a, b, c;&lt;/code&gt; line only ranks layers the browser hasn't already met. Anything that injects a layer block &lt;em&gt;earlier&lt;/em&gt; in the document than that statement wins the race and loses the priority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unlayered styles beat all layered styles.&lt;/strong&gt; A second cache without the layer option doesn't "join" the cascade — it sits above every layer, silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Injection position is a cascade decision.&lt;/strong&gt; In a layered world, "put my styles first" means "make my layer lowest." &lt;code&gt;prepend&lt;/code&gt; and &lt;code&gt;injectFirst&lt;/code&gt; are answers to a question layers already solved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test client-rendered pages.&lt;/strong&gt; If your styles are inserted post-hydration, your SSR screenshots are not evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two styling systems, one integration, zero extras.&lt;/strong&gt; &lt;code&gt;enableCssLayer: true&lt;/code&gt; and the ordering line — anything added "for safety" is a regression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First declaration ranks the layer.&lt;/strong&gt; Read your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; order, not just your stylesheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preflight is a weapon&lt;/strong&gt; once it outranks your components: borders, backgrounds and opacity vanish together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-only breakage hides behind auth.&lt;/strong&gt; Audit the pages that render after hydration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the failure modes down as rules.&lt;/strong&gt; Both of these looked correct; the note in the repo is what stops the third attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 39 closes the fourth batch with the third polish montage: brand and pack size on scanned items, the Android alert that silently drops its fourth button, the edge-to-edge keyboard trap in bottom sheets, and the day the app got a space in its name.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the "recommended" line in your CSS setup that predates cascade layers — and have you checked what it does now?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>react</category>
      <category>nextjs</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The admin console: a web app on a mobile app's Cognito pool</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:53:37 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/the-admin-console-a-web-app-on-a-mobile-apps-cognito-pool-3ka</link>
      <guid>https://dev.to/hokagedemehin/the-admin-console-a-web-app-on-a-mobile-apps-cognito-pool-3ka</guid>
      <description>&lt;p&gt;Every support question in &lt;a href="https://dev.to/hokagedemehin/the-activity-log-what-users-did-without-their-words-5aaa"&gt;Part 36&lt;/a&gt; implied a place to answer it from. The web app had been a landing page and legal documents; it needed to become a &lt;strong&gt;console&lt;/strong&gt; — a dashboard, a deletion queue, user lookup, announcements, the activity feed. The Next.js + MUI half of the monorepo finally earned its keep.&lt;/p&gt;

&lt;p&gt;The architectural wrinkle that shaped everything: the console and the phone app share &lt;strong&gt;one Cognito user pool&lt;/strong&gt;. Every shopper is a valid Cognito user. Which means a successful sign-in to the console proves precisely nothing about whether you belong there.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;strong&gt;The front door signs non-admins straight back out&lt;/strong&gt;: sign-up is removed from the web auth page, and a valid session that isn't in an admin group is ended with a plain message — because on a shared pool, a session proves identity, not membership. That's front-door only; the schema's group rules stay the real boundary, and the route guard's group check is defence in depth for a session that changes under you. The deletion queue renders its &lt;strong&gt;runbook inline&lt;/strong&gt;; the user lookup surfaces the S3 identity id that's unrecoverable after deletion. Every admin table spreads &lt;strong&gt;one defaults object&lt;/strong&gt; — which is also the dark-mode fix, because the table library reads the light palette's literals under CSS-variables mode and its colour maths throws on &lt;code&gt;var(...)&lt;/code&gt;. Forms get their own routes, never dialogs; labels above fields, never floating.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 37 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The front door: a valid session proves nothing
&lt;/h2&gt;

&lt;p&gt;Two decisions make the console admin-only &lt;em&gt;at the door&lt;/em&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No sign-up on the web auth page.&lt;/strong&gt; Accounts are created in the phone app — or by hand, for admins. The console has no reason to mint users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A successful sign-in that isn't in &lt;code&gt;SUPER_ADMIN&lt;/code&gt; or &lt;code&gt;ADMIN&lt;/code&gt; is signed straight back out&lt;/strong&gt;, with "this console is for administrators." The check reads the groups claim off the access token, in one module that is the single definition of the gate. The route guard carries the same check as &lt;strong&gt;defence in depth&lt;/strong&gt; — for a session that changes under you (a demotion mid-session, a stale tab).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the honest caveat, written in the code and the docs: &lt;strong&gt;all of that is front-door only.&lt;/strong&gt; The API's &lt;code&gt;allow.groups&lt;/code&gt; rules are the real boundary. Models on plain authenticated access — profiles, lists, receipts — remain readable by &lt;em&gt;any&lt;/em&gt; signed-in user through the API, because that's what the phone app needs. The console's gate keeps the wrong people from seeing controls whose writes would fail; it isn't what stops the writes. Knowing which of your walls is load-bearing is most of security architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screens, and the decision in each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/deletion-requests&lt;/code&gt;&lt;/strong&gt; — the queue of requests filed from the public website (in-app deletion runs the Lambda immediately and never files one, per &lt;a href="https://dev.to/hokagedemehin/account-deletion-for-real-immediate-guest-round-trip-admin-initiated-3k0n"&gt;Part 32&lt;/a&gt;). The fulfilment &lt;strong&gt;runbook renders inline&lt;/strong&gt;, next to the row's status, so working a request no longer means opening a markdown file beside the console. A runbook nobody has open is a runbook nobody follows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/users&lt;/code&gt;&lt;/strong&gt; — lookup for support questions, and one deliberately prominent detail: each user's row shows the Cognito sub &lt;strong&gt;and the S3 identity id&lt;/strong&gt;. The identity id is unrecoverable once the Cognito user is deleted, and the deletion runbook needs it first — so the screen puts it where you'll capture it before you do anything irreversible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/announcements&lt;/code&gt;&lt;/strong&gt; — a composer with a &lt;strong&gt;live phone preview&lt;/strong&gt; (the announcement rendered as the app will show it) and a typed confirmation for anything blocking (&lt;a href="https://dev.to/hokagedemehin/in-app-announcements-talking-to-users-after-the-build-shipped-28d6"&gt;Part 35&lt;/a&gt;). &lt;strong&gt;&lt;code&gt;/activity&lt;/code&gt;&lt;/strong&gt; — the feed, with its filters in the URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inviting an admin&lt;/strong&gt; — a dialog on the users page calling a group-gated mutation, with distinct toasts for "invited fresh" vs "promoted an existing user," and &lt;strong&gt;&lt;code&gt;retry: 0&lt;/code&gt;&lt;/strong&gt; on the mutation, because a slow identity-provider call that retries is how you double-invite someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  One defaults object for every table (and the dark-mode fix inside it)
&lt;/h2&gt;

&lt;p&gt;Admin lists are &lt;code&gt;material-react-table&lt;/code&gt;, and the first two screens each had their own configuration. The third would have had a third. So: one &lt;code&gt;adminTableDefaults()&lt;/code&gt; that &lt;strong&gt;every table spreads first&lt;/strong&gt;, one stat-card component, and a rule for the cards themselves — a stat earns its place only if it changes what the admin does next, and the "alert" style means &lt;em&gt;something is wrong&lt;/em&gt;, never &lt;em&gt;something is big&lt;/em&gt;. (Yes, that's &lt;a href="https://dev.to/hokagedemehin/the-home-dashboard-one-screen-that-earns-the-default-tab-5ek7"&gt;Part 26's&lt;/a&gt; Home rule, applied to a different audience.)&lt;/p&gt;

&lt;p&gt;The defaults object is also where the dark-mode bug got fixed, and the bug is worth understanding. The table library paints its own surfaces by reading &lt;code&gt;theme.palette.*&lt;/code&gt;. Under MUI's CSS-variables colour-scheme mode, those values are the &lt;strong&gt;light scheme's literal colours&lt;/strong&gt; — the flipping references live in &lt;code&gt;theme.vars.*&lt;/code&gt; — so every surface the library painted for itself stayed white under &lt;code&gt;.dark&lt;/code&gt;. The obvious fix, handing it CSS variables, fails differently: the library runs &lt;code&gt;darken()&lt;/code&gt;/&lt;code&gt;lighten()&lt;/code&gt; over its base background colour, and MUI's colour maths &lt;strong&gt;throws on &lt;code&gt;var(...)&lt;/code&gt;&lt;/strong&gt;. So the surfaces are painted through &lt;code&gt;sx&lt;/code&gt; (which does no maths) — and only the two properties the library assigns &lt;em&gt;directly&lt;/em&gt; without maths take variables, which happens to be the only way to reach the row-action popover, the one surface &lt;code&gt;sx&lt;/code&gt; can't touch. A fix that's three lines and a paragraph of reasoning, which is why the paragraph lives in the code.&lt;/p&gt;

&lt;p&gt;Tables also got a fixed frame: sticky header, body scrolling inside a bounded container, toolbar and pagination pinned — with head cells kept opaque because rows scroll &lt;em&gt;under&lt;/em&gt; them (the web cousin of &lt;a href="https://dev.to/hokagedemehin/the-polish-pass-smart-add-pull-to-refresh-sticky-headers-tighter-archive-2bnn"&gt;Part 12's&lt;/a&gt; sticky-header lesson).&lt;/p&gt;

&lt;h2&gt;
  
  
  Forms get routes; labels sit above fields
&lt;/h2&gt;

&lt;p&gt;Two house rules the console enforces everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forms get their own route&lt;/strong&gt; (&lt;code&gt;/announcements/new&lt;/code&gt;, &lt;code&gt;/announcements/[id]&lt;/code&gt;), never a dialog on the list page. A form in a dialog can't be linked, refreshed, or half-finished safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels above fields — never MUI's floating labels.&lt;/strong&gt; One shared form-field component (&lt;code&gt;FormControl&lt;/code&gt; → &lt;code&gt;FormLabel&lt;/code&gt; → input), and pickers get no &lt;code&gt;label&lt;/code&gt; prop for the same reason. Floating labels collide with placeholder text, autofill, and every screen reader test I've run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every destructive action goes through one shared delete dialog; &lt;code&gt;confirm()&lt;/code&gt; is banned. Web reads page through the same &lt;a href="https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd"&gt;load-everything helper&lt;/a&gt; ported from mobile; admin writes use the user-pool auth mode, never the identity-pool mode the public deletion form uses.&lt;/p&gt;

&lt;p&gt;The console also got light/dark/system theming — but default &lt;strong&gt;light&lt;/strong&gt;, not system: the public landing was designed light, and dark is an explicit per-browser choice. And the reason the theme toggle sits in this post rather than being trivial is the next part: making MUI and Tailwind coexist broke in two spectacular ways before it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A shared identity pool means sessions prove identity, not membership.&lt;/strong&gt; Gate at the door, enforce at the API, and know which is which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the runbook next to the row.&lt;/strong&gt; Documentation you have to go find is documentation that gets skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surface the value you'll need before the irreversible step&lt;/strong&gt; — the identity id on the user row exists for the deletion runbook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One defaults object per library&lt;/strong&gt;, spread first, and put the workaround's &lt;em&gt;reasoning&lt;/em&gt; in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Library colour maths and CSS variables don't mix&lt;/strong&gt; — paint surfaces with plain styles, pass variables only where nothing computes on them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retry: 0&lt;/code&gt; on anything that creates.&lt;/strong&gt; Retries turn slow into duplicate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 38 is the war story this console produced: &lt;strong&gt;MUI vs Tailwind&lt;/strong&gt; — two CSS systems, one cascade, and the two one-line changes that each silently erased every input outline in the app.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If your admin app shares a user pool with your consumer app, what happens today when a regular user signs in to it?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>aws</category>
      <category>react</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The activity log: what users did, without their words</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:51:27 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/the-activity-log-what-users-did-without-their-words-5aaa</link>
      <guid>https://dev.to/hokagedemehin/the-activity-log-what-users-did-without-their-words-5aaa</guid>
      <description>&lt;p&gt;The first real support question was small and unanswerable: &lt;em&gt;"I scanned a receipt on Tuesday and I can't see it — did it save?"&lt;/em&gt; The database could say whether a receipt row existed. It couldn't say what the person had &lt;em&gt;done&lt;/em&gt; — whether they'd scanned and cancelled, scanned and hit an error, or scanned twice and deleted one. The app had state but no history of actions.&lt;/p&gt;

&lt;p&gt;An activity log is the standard answer, and the standard answer has a standard failure mode: a table that quietly accumulates copies of every private thing users type, under different access rules than the data it copied, with a different retention lifetime. The whole design of this one is about not building that.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Users write their own rows; only admins read them. &lt;strong&gt;No user text, ever&lt;/strong&gt; — a row carries an action key, a count and ids, and the admin UI composes the sentence from a constants file (28 keys, mirrored across both apps). &lt;strong&gt;One row per user &lt;em&gt;action&lt;/em&gt;, never per write&lt;/strong&gt; — a voice capture of 30 items is one row with &lt;code&gt;count: 30&lt;/code&gt;. &lt;strong&gt;Never log inside a mutation function&lt;/strong&gt; — those re-run on retry and offline replay; log at the tap, with a queue that keeps the &lt;em&gt;original&lt;/em&gt; timestamp when signal returns. Deliberately unlogged: item ticks (the ledger already is that record, at 30× the volume). And the repo's first &lt;strong&gt;GSIs&lt;/strong&gt;: date-partitioned and per-user, paged with limits — the one screen that must &lt;em&gt;not&lt;/em&gt; use the load-everything helper.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 36 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: no user text, ever
&lt;/h2&gt;

&lt;p&gt;A row looks like this in spirit: &lt;code&gt;{ userId, action: "receipt.scanned", count: 12, targetId, at }&lt;/code&gt;. No list name, no item name, no store, no note — &lt;strong&gt;nothing the user typed or the parser read.&lt;/strong&gt; The admin feed composes the human sentence ("scanned a receipt with 12 lines") from a constants file of 28 action keys, mirrored verbatim between the mobile app that writes and the web console that reads.&lt;/p&gt;

&lt;p&gt;Three things fall out of that one rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different auth, different lifetime, no leak.&lt;/strong&gt; Activity rows are admin-readable and live a year; shopping items are user-private and live forever. Copying item names into the log would put private text under looser access with a different retention policy. Ids and keys leak nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewording needs no migration; a new action needs no deploy.&lt;/strong&gt; The sentence lives in the UI. Change the words, change nothing in the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;a href="https://dev.to/hokagedemehin/your-prod-data-faked-one-way-replication-with-deterministic-pii-masking-58cl"&gt;replication masker&lt;/a&gt; passes it straight through.&lt;/strong&gt; A table with no free text has nothing to fake; the mirror copies it structurally, safely, by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule 2: one row per action, never per write
&lt;/h2&gt;

&lt;p&gt;The unit of logging is what the &lt;em&gt;user did&lt;/em&gt;, not what the code did. A voice capture that creates 30 items is &lt;strong&gt;one row, &lt;code&gt;count: 30&lt;/code&gt;&lt;/strong&gt;. A receipt save that fans out into N item updates, a bulk add, and a receipt create is &lt;strong&gt;one &lt;code&gt;receipt.scanned&lt;/code&gt;&lt;/strong&gt;. If the log mirrored the write pattern, a single afternoon's shopping would produce a hundred rows nobody could read, and the count of "receipts scanned" would be wrong by a factor of the fan-out.&lt;/p&gt;

&lt;p&gt;Which leads directly to the rule that took the most discipline: &lt;strong&gt;never log from inside a mutation function.&lt;/strong&gt; Mutation functions re-run — on retry, and on &lt;a href="https://dev.to/hokagedemehin/working-offline-in-the-cereal-aisle-tanstack-query-offline-on-amplify-5cn0"&gt;offline replay&lt;/a&gt; after an app kill. A log call inside one would double-count every retried action and re-log yesterday's supermarket at the moment signal returned. The log call lives &lt;strong&gt;at the call site, at the tap&lt;/strong&gt; — the moment the human did the thing. And because a mutation paused offline never reaches its success callback, the logger has its own queue for that case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: what is deliberately &lt;em&gt;not&lt;/em&gt; logged
&lt;/h2&gt;

&lt;p&gt;The absences were designed as carefully as the presences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Item ticks.&lt;/strong&gt; &lt;code&gt;checkedAt&lt;/code&gt; on the item — the &lt;a href="https://dev.to/hokagedemehin/the-purchase-ledger-when-did-you-actually-buy-that-5age"&gt;purchase ledger&lt;/a&gt; — already &lt;em&gt;is&lt;/em&gt; that record, at roughly thirty times the volume of every other action combined. Logging it twice would drown the feed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The auto-created default list&lt;/strong&gt;, push-token refreshes, and writes to the shared product cache — machine actions, not human ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account deletion&lt;/strong&gt; — the deletion Lambda erases its own rows, and the public web form is a guest who can't write activity at all. (Deletions are counted, not logged: the anonymous rollups from &lt;a href="https://dev.to/hokagedemehin/account-deletion-for-real-immediate-guest-round-trip-admin-initiated-3k0n"&gt;Part 32&lt;/a&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The logger never throws and is never awaited
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;logActivity()&lt;/code&gt; is fire-and-forget by contract: it can't fail the action it's describing. On failure — offline, transient — it buffers to per-user local storage (capped, with a seven-day cutoff so an abandoned device doesn't flush a month of stale rows later), and flushes on foreground or reconnect. The flush &lt;strong&gt;keeps the original timestamp&lt;/strong&gt;: an action taken in a supermarket yesterday must appear at yesterday's time, not at the moment the phone found signal in the car park. A log whose timestamps mean "when we managed to write it" is a log of connectivity, not behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo's first GSIs — and the screen that must not &lt;code&gt;listAll&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Every other read in this app pages through the &lt;a href="https://dev.to/hokagedemehin/you-cant-group-by-in-dynamodb-so-the-client-does-the-counting-4dnd"&gt;load-everything helper&lt;/a&gt; — fetch all pages, filter client-side — because the data is per-user and small. An activity table is neither. Loading a year of rows to render ten is the bug the paging rule exists to prevent, &lt;em&gt;inverted&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So this model got the repo's first secondary indexes: &lt;strong&gt;&lt;code&gt;activityByDate&lt;/code&gt;&lt;/strong&gt; — a bounded partition per local day — and &lt;strong&gt;&lt;code&gt;activityByUser&lt;/code&gt;&lt;/strong&gt;, both paged with a &lt;code&gt;limit&lt;/code&gt; and a continuation token. A date-range query in the admin feed walks day partitions &lt;em&gt;backwards&lt;/em&gt;, resuming from a &lt;code&gt;{date, token}&lt;/code&gt; cursor and capped at 31 days per page; picking a person switches to the user index instead. The feed's filters live in the URL (&lt;code&gt;?from=&amp;amp;to=&amp;amp;user=&lt;/code&gt;) so a refresh or a shared link keeps the view — which, in the App Router, means the page needs a Suspense boundary or it stops prerendering and the build fails. A small tax on a good habit.&lt;/p&gt;

&lt;p&gt;Retention is a &lt;strong&gt;TTL&lt;/strong&gt; attribute (one year out), set through the table wrapper — which needed two extra permissions on the provider role that nothing else had ever required. And TTL deletion emits stream &lt;code&gt;REMOVE&lt;/code&gt; events, so when production rows expire, the mirrored dev copies expire with them. Retention policy replicates itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log keys and counts, compose sentences in the UI.&lt;/strong&gt; No user text means no leak, no migration for rewording, and a masker with nothing to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unit is the human action.&lt;/strong&gt; One row per tap, however many writes it fans out into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never log where code re-runs.&lt;/strong&gt; Mutation functions retry and replay; the call site doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what you won't log&lt;/strong&gt;, especially anything another field already records.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fire-and-forget, buffer on failure, keep the original time.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which table breaks your paging convention&lt;/strong&gt; — and give it real indexes before it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 37 walks through the door of the &lt;strong&gt;admin console&lt;/strong&gt; — a web app bolted onto a mobile app's identity pool, where a valid sign-in proves nothing, and the front door has to send the wrong people straight back out.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What does your audit log accidentally copy — and would you be comfortable if its access rules were the loosest in your system?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dynamodb</category>
      <category>architecture</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>In-app announcements: talking to users after the build shipped</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:50:12 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/in-app-announcements-talking-to-users-after-the-build-shipped-28d6</link>
      <guid>https://dev.to/hokagedemehin/in-app-announcements-talking-to-users-after-the-build-shipped-28d6</guid>
      <description>&lt;p&gt;The day after the &lt;a href="https://dev.to/hokagedemehin/shipping-to-both-stores-the-release-runbook-29ma"&gt;store launch&lt;/a&gt;, a fact about the architecture became a product problem: &lt;strong&gt;there was no way to tell users anything.&lt;/strong&gt; Over-the-air updates aren't installed, so nothing reaches a running app; the store's "What's New" only reaches people who read store pages; and push notifications hadn't shipped yet. A bug found on launch day would have been invisible to every installed copy until the next review cycle.&lt;/p&gt;

&lt;p&gt;In-app announcements are the channel that fixes that — a global model the admin console writes and the app reads. The feature is mostly small decisions, and two of them are the kind you only find with a real device in your hand.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A global &lt;code&gt;Announcement&lt;/code&gt; model (no &lt;code&gt;userId&lt;/code&gt;, admin-write, everyone-read). &lt;strong&gt;Severity picks the shape&lt;/strong&gt; (INFO = dismissible strip, IMPORTANT = dialog shown once, CRITICAL = full-screen takeover); &lt;strong&gt;kind&lt;/strong&gt; only picks the wording. Targeting is by the version users are &lt;strong&gt;running&lt;/strong&gt;, compared &lt;strong&gt;segment by segment&lt;/strong&gt; — a string compare sorts "1.10.0" below "1.9.0" and would mis-target every release after the ninth. &lt;code&gt;actionUrl: "store"&lt;/code&gt; is a sentinel the app resolves per platform. The force-update wall &lt;strong&gt;fails open by construction&lt;/strong&gt;: any error, timeout or empty cache renders nothing, so offline is never a wall. Seen state loads &lt;em&gt;inside&lt;/em&gt; the component, never at root; a new account is never walked through the backlog. And from device testing: messages must arrive &lt;strong&gt;live&lt;/strong&gt; (a subscription feeding the query cache), and a modal that must appear unprompted &lt;strong&gt;cannot be a bottom sheet&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 35 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity picks the shape; kind picks the words
&lt;/h2&gt;

&lt;p&gt;Two enums, deliberately orthogonal. &lt;strong&gt;Severity&lt;/strong&gt; decides &lt;em&gt;how&lt;/em&gt; a message interrupts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INFO&lt;/code&gt; → a dismissible strip on Home. "What's new in 1.0.3."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IMPORTANT&lt;/code&gt; → a centred dialog, shown once. "Your receipts from Tuesday were re-processed."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CRITICAL&lt;/code&gt; → a full-screen takeover. "This version can't sync — please update."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Kind&lt;/strong&gt; (&lt;code&gt;WHATS_NEW&lt;/code&gt; / &lt;code&gt;UPDATE_AVAILABLE&lt;/code&gt; / &lt;code&gt;NOTICE&lt;/code&gt;) only drives the icon and label. Keeping them separate means an update nudge can be an INFO strip &lt;em&gt;or&lt;/em&gt; a CRITICAL wall depending on how bad the old build is — the admin chooses the interruption level independently of the message type. Every message also lands in a What's-new archive screen, badged while unread, so a dismissed strip isn't gone forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Target the version people are &lt;em&gt;running&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The reader has to already be installed for a message to arrive, so targeting is by app version: inclusive &lt;code&gt;minVersion&lt;/code&gt;/&lt;code&gt;maxVersion&lt;/code&gt; on the row. The bug-in-waiting was the comparison. A &lt;strong&gt;string compare sorts "1.10.0" below "1.9.0"&lt;/strong&gt; — perfectly, silently wrong, and it would have mis-targeted every release after the ninth. Versions are compared &lt;strong&gt;segment by segment&lt;/strong&gt; as numbers, in one small module the whole app shares.&lt;/p&gt;

&lt;p&gt;The action link got a sentinel: &lt;code&gt;actionUrl: "store"&lt;/code&gt;. The admin never pastes a store URL — the app resolves the sentinel to the right listing per platform, so a message can't carry an App Store link to Android users or vice versa. Any other &lt;code&gt;actionUrl&lt;/code&gt; deep-links into the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The force-update wall fails open — by construction
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;CRITICAL&lt;/code&gt; announcement can be marked &lt;strong&gt;blocking&lt;/strong&gt;: a wall the user can't dismiss, for "this version corrupts data" emergencies. The design principle is that the wall must be impossible to raise by accident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any fetch error, timeout or empty cache renders nothing.&lt;/strong&gt; An offline user is never walled; a backend hiccup is never a lockout. The wall only appears when the app has positively received a blocking row.&lt;/li&gt;
&lt;li&gt;Only &lt;code&gt;CRITICAL&lt;/code&gt; may block; a blocking row &lt;strong&gt;must&lt;/strong&gt; carry an &lt;code&gt;actionUrl&lt;/code&gt; (a wall with no exit is a hostage situation); the composer demands a &lt;strong&gt;typed confirmation&lt;/strong&gt; before publishing one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;active: false&lt;/code&gt; is a &lt;strong&gt;one-click kill switch&lt;/strong&gt; that releases everyone.&lt;/li&gt;
&lt;li&gt;A blocking row &lt;strong&gt;ignores the seen-list&lt;/strong&gt; — acknowledging a wall must not walk through it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Fails open" is the whole security posture, inverted: for a feature whose failure mode is &lt;em&gt;locking out your own users&lt;/em&gt;, the safe default is to do nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seen state: per user, loaded in the right place
&lt;/h2&gt;

&lt;p&gt;Dismissals are local state (announcements don't roam — you dismiss on each device), keyed per user and held in the preferences store so the strip, the dialog and the archive can't disagree about what's been seen. Where it's &lt;em&gt;loaded&lt;/em&gt; matters: &lt;strong&gt;inside the &lt;code&gt;&amp;lt;Announcements /&amp;gt;&lt;/code&gt; component, never at the root layout&lt;/strong&gt; — the root runs before the per-user storage setup from &lt;a href="https://dev.to/hokagedemehin/polish-pass-2-a-dozen-small-things-that-make-it-feel-real-bg0"&gt;Part 27&lt;/a&gt; has keyed storage to the signed-in account, and would read the &lt;em&gt;previous&lt;/em&gt; user's seen-list.&lt;/p&gt;

&lt;p&gt;Two more gates: &lt;code&gt;&amp;lt;Announcements /&amp;gt;&lt;/code&gt; mounts inside the onboarding gate's children, so a message can never interrupt currency onboarding. And an &lt;strong&gt;account-age gate&lt;/strong&gt;: non-blocking rows published &lt;em&gt;before&lt;/em&gt; the user's profile was created never interrupt or badge — a brand-new user (or an old account on a new device, since seen state doesn't roam) must not be walked through six weeks of release notes on first open. Blocking rows are exempt, because a force-update wall must stop a fresh install too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What device testing found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;They didn't arrive on their own.&lt;/strong&gt; The query refetched on mount, foreground and reconnect — sensible for most data, and exactly the wrong shape for a message published "now": an open app learned nothing until the user backgrounded it. The fix was a live subscription (&lt;code&gt;observeQuery&lt;/code&gt;) holding one AppSync connection and pushing the filtered list &lt;strong&gt;straight into the React Query cache&lt;/strong&gt; — so every consumer updates together, the persisted cache still serves offline, and a failed socket degrades to what it did before. No schema change: &lt;code&gt;read&lt;/code&gt; permission already covers &lt;code&gt;listen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The IMPORTANT sheet could not be made to work.&lt;/strong&gt; It began as a bottom sheet, which opened at handle height on Android and not at all on iOS. The cause was structural: a bottom sheet needs an &lt;em&gt;imperative&lt;/em&gt; &lt;code&gt;present()&lt;/code&gt; call &lt;strong&gt;and&lt;/strong&gt; a measured container — and fired from an effect at app start, it had neither. Fixing the content measurement just moved the problem to the container. Meanwhile INFO and CRITICAL never missed, because both render &lt;strong&gt;declaratively from state&lt;/strong&gt;. So the sheet was rebuilt as a centred &lt;code&gt;&amp;lt;Modal&amp;gt;&lt;/code&gt; dialog: visible is a boolean, height is its content, nothing to measure and nothing to time. The rule worth keeping: &lt;strong&gt;a surface that must appear unprompted should not depend on a measurement.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Small conventions that rode along
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The body is a JSON field in the same shape as the legal pages (&lt;code&gt;{paragraphs, bullets}&lt;/code&gt;) — a JSON &lt;em&gt;string&lt;/em&gt; on write, string-or-parsed on read (the &lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;Part 14&lt;/a&gt; lesson, still paying).&lt;/li&gt;
&lt;li&gt;Time filtering happens in the query function, not a memo — the React Compiler rejects clock reads there — so a scheduled row appears on the next refetch rather than to the second. Acceptable; a scheduled announcement is not a countdown.&lt;/li&gt;
&lt;li&gt;Archive cards key on the row id, never on their text: release-note bullets repeat across versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ship the reader before you need it.&lt;/strong&gt; An announcement system reaches only builds that already contain it; the first version to include it protects every version after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare versions as segments.&lt;/strong&gt; "1.10.0" &amp;lt; "1.9.0" as strings is a bug you ship on your tenth release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force-update must fail open.&lt;/strong&gt; The only safe default for a feature that can lock out users is silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orthogonal enums&lt;/strong&gt;: interruption level and message type are different decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load per-user state where per-user storage is ready&lt;/strong&gt; — not at the root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unprompted surfaces render from state&lt;/strong&gt;, never from an imperative call that needs a measurement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 36: the &lt;strong&gt;activity log&lt;/strong&gt; — what users did, for support and usage questions, with a rule that keeps user text out of it entirely, one row per &lt;em&gt;action&lt;/em&gt; rather than per write, and the repo's first secondary indexes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If your app has no OTA updates and no push, how would you tell every installed copy about a bug tomorrow?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Emails in the user's language, and the frozen Cognito pool</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:47:36 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/emails-in-the-users-language-and-the-frozen-cognito-pool-17h7</link>
      <guid>https://dev.to/hokagedemehin/emails-in-the-users-language-and-the-frozen-cognito-pool-17h7</guid>
      <description>&lt;p&gt;After &lt;a href="https://dev.to/hokagedemehin/the-app-speaks-19-languages-tiered-i18n-and-the-ai-translation-pipeline-14"&gt;Part 33&lt;/a&gt;, a user could pick Arabic in onboarding and get an app that flipped right-to-left within a restart — and then receive a verification email in English. The emails hadn't kept up, and the reason was structural: &lt;strong&gt;the two email senders fire in places where no profile row exists.&lt;/strong&gt; The sign-up verification trigger runs &lt;em&gt;before&lt;/em&gt; the user's profile is created. The forgot-password trigger runs for someone &lt;em&gt;signed out&lt;/em&gt;. Neither can read &lt;code&gt;UserProfile.locale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was two lines on the client and one attribute in Cognito — and then a deploy that failed on every branch for reasons the sandbox was structurally unable to show me.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Cognito's standard &lt;code&gt;locale&lt;/code&gt; attribute is a &lt;strong&gt;mirror&lt;/strong&gt; of the app's language for the one consumer that can't reach a profile: the app writes it at sign-up (the language the sign-up screen is rendered in — so the verification email matches by construction) and on every language change. The trigger reads it, treats it strictly as a &lt;strong&gt;whitelist key&lt;/strong&gt; (user-controlled text is never echoed into HTML), falls back to English, and flips Arabic to &lt;code&gt;dir="rtl"&lt;/code&gt;. Deletion emails stay English &lt;em&gt;by design&lt;/em&gt; — a per-address language lookup would turn the no-oracle guest form into an oracle. The war story: declaring &lt;code&gt;locale&lt;/code&gt; in &lt;code&gt;defineAuth.userAttributes&lt;/code&gt; failed every branch deploy with &lt;strong&gt;"Invalid AttributeDataType input"&lt;/strong&gt;, because a user pool's attribute schema is &lt;strong&gt;frozen at creation&lt;/strong&gt; — and the sandbox looked fine only because its pool was born after the change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 34 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The attribute is a mirror, not a source of truth
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UserProfile.locale&lt;/code&gt; stays the app's real language setting. The Cognito &lt;code&gt;locale&lt;/code&gt; attribute exists for exactly one reason: the &lt;code&gt;customMessage&lt;/code&gt; trigger from &lt;a href="https://dev.to/hokagedemehin/branded-cognito-emails-the-custommessage-trigger-in-20-minutes-4c3b"&gt;Part 28&lt;/a&gt; receives &lt;code&gt;event.request.userAttributes&lt;/code&gt; &lt;em&gt;inline&lt;/em&gt; — no lookup, no latency, and available pre-confirmation when nothing else about the user exists yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The write side is three touch points, all client-side:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sign-up&lt;/strong&gt; passes &lt;code&gt;locale&lt;/code&gt; as a user attribute — the app's &lt;em&gt;resolved&lt;/em&gt; UI language at that moment (&lt;code&gt;"es"&lt;/code&gt;, never the &lt;code&gt;"auto"&lt;/code&gt; sentinel). Which is the language the person is currently reading the sign-up screen in — so the verification email matches &lt;strong&gt;by construction&lt;/strong&gt;, without a round trip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every explicit language change&lt;/strong&gt; (onboarding, the More → Language picker) fires a best-effort attribute update alongside the profile write.&lt;/li&gt;
&lt;li&gt;Nothing on sign-in — the attribute rides along from whenever it was last set.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The two lines at sign-up had been added in Part 33 "for later." This was later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read side: whitelist, never echo
&lt;/h2&gt;

&lt;p&gt;The trigger localises verification and reset emails from a typed translations module — and the one rule that matters for security: &lt;strong&gt;the attribute is treated strictly as a whitelist key.&lt;/strong&gt; It's user-controlled text; the client can write anything into it. So it's looked up against the supported-language map and &lt;em&gt;never&lt;/em&gt; echoed into the HTML. An unknown or hostile value falls back to English silently. The verification included rendering the real handler with Spanish, Arabic, an unsupported locale, and a deliberately hostile string, and screenshotting every output.&lt;/p&gt;

&lt;p&gt;Arabic flips the email shell to &lt;code&gt;dir="rtl"&lt;/code&gt; — accent borders and card icons hug the reading edge — while the code digits stay LTR, because a six-digit code is not prose. The admin-invite email stays English: the console is English-only, and the invite goes to an admin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The email that stays English on purpose
&lt;/h2&gt;

&lt;p&gt;Deletion-request emails from the public web form are English &lt;em&gt;by design&lt;/em&gt;, and the reason is a small security argument. A guest form has &lt;strong&gt;no trustworthy language signal&lt;/strong&gt; — the browser locale is trivially spoofable and says nothing about the account. The tempting alternative — look up the address's stored language and reply in it — would make the request mutation &lt;strong&gt;behave differently for known addresses than unknown ones&lt;/strong&gt;. That's the &lt;a href="https://dev.to/hokagedemehin/account-deletion-for-real-immediate-guest-round-trip-admin-initiated-3k0n"&gt;email oracle&lt;/a&gt; Part 32 spent a section preventing, reintroduced through translation. A language leak is still a leak; English for everyone is the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extended tier came free
&lt;/h2&gt;

&lt;p&gt;When the thirteen extended-tier languages landed, the email templates followed — as a &lt;strong&gt;Lambda-only change&lt;/strong&gt;: existing users with a stored locale started receiving translated emails on deploy, with no app update at all. The email's button labels and screen names are taken from the app's own locale files, so "Speak" is the same word in the welcome email as on the Home screen. One vocabulary, two renderers — a rule this project keeps rediscovering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The war story: a schema that can't change
&lt;/h2&gt;

&lt;p&gt;Somewhere along the way — because it looked like the correct, declarative thing to do — &lt;code&gt;locale&lt;/code&gt; got declared in &lt;code&gt;defineAuth&lt;/code&gt;'s &lt;code&gt;userAttributes&lt;/code&gt;. The sandbox deployed fine. The app worked. Then &lt;strong&gt;every dev-branch deploy failed&lt;/strong&gt;, build after build, with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Invalid AttributeDataType input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause is a Cognito fact worth memorising: &lt;strong&gt;a user pool's attribute schema is frozen at creation.&lt;/strong&gt; The update API has no schema parameter at all. When CloudFormation is asked to change the schema of an existing pool, its handler falls back to the one thing it &lt;em&gt;can&lt;/em&gt; call — add-custom-attributes — which rejects a &lt;em&gt;standard&lt;/em&gt; attribute name (&lt;code&gt;locale&lt;/code&gt; is OIDC-standard, not custom) with that error. Every branch deploy tried, every branch deploy failed.&lt;/p&gt;

&lt;p&gt;And the sandbox? &lt;strong&gt;Its pool had been created &lt;em&gt;after&lt;/em&gt; the declaration was added&lt;/strong&gt;, so &lt;code&gt;locale&lt;/code&gt; was in the creation-time schema and there was nothing to update. The sandbox wasn't lying; it was structurally incapable of reproducing the failure. "It works in the sandbox" and "it deploys to a branch" are different claims when the resource in question can only be shaped once.&lt;/p&gt;

&lt;p&gt;The twist that made it worse: the declaration was &lt;strong&gt;also a no-op&lt;/strong&gt;. Every Cognito pool is born with the full set of standard OIDC attributes — &lt;code&gt;locale&lt;/code&gt; included, mutable and optional — and an app client with no explicit read/write attribute lists can already use all of them. The line had done nothing except break deploys.&lt;/p&gt;

&lt;p&gt;The fix was deletion: remove the declaration. Schema &lt;em&gt;removals&lt;/em&gt; are ignored on update — the documented-safe direction — so the sandbox pool keeps its harmless drift and the branches deploy again. The rule now lives in the repo's own instructions: &lt;strong&gt;never declare a standard attribute; they already exist.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mirror, don't move.&lt;/strong&gt; The profile stays the source of truth; the auth attribute exists for the one reader that can't reach it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the language the user is reading.&lt;/strong&gt; The sign-up screen's own locale is the correct email language, by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-controlled attributes are keys, never content.&lt;/strong&gt; Whitelist, fall back, never echo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translation can be an oracle.&lt;/strong&gt; If a response changes based on whether an address is known, the language of the response counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some resources are shaped once.&lt;/strong&gt; A user pool's schema is frozen at creation — and a young sandbox will never show you that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox age is a variable.&lt;/strong&gt; Recreate it occasionally, or at least know which resources it created &lt;em&gt;after&lt;/em&gt; the code changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 35: the only channel to users &lt;em&gt;after&lt;/em&gt; a build has shipped — &lt;strong&gt;in-app announcements&lt;/strong&gt; with version targeting against what people are actually running, severity that picks the shape, and a force-update wall that fails open by construction.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's your platform's "frozen at creation" resource — the one where the sandbox says yes and production says never?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cognito</category>
      <category>i18n</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The app speaks 19 languages: tiered i18n and the AI translation pipeline</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:45:40 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/the-app-speaks-19-languages-tiered-i18n-and-the-ai-translation-pipeline-14</link>
      <guid>https://dev.to/hokagedemehin/the-app-speaks-19-languages-tiered-i18n-and-the-ai-translation-pipeline-14</guid>
      <description>&lt;p&gt;The codebase survey was blunt: &lt;strong&gt;zero i18n infrastructure&lt;/strong&gt;, roughly 660 user-facing text nodes across 53 files, a dozen alert dialogs, forty toasts, screen titles scattered across layout files. English was load-bearing everywhere.&lt;/p&gt;

&lt;p&gt;The reason to fix it &lt;em&gt;then&lt;/em&gt;, rather than "after launch", was the &lt;a href="https://dev.to/hokagedemehin/shipping-to-both-stores-the-release-runbook-29ma"&gt;closed-test window&lt;/a&gt;: Google Play makes you sit in testing for fourteen days regardless, and fourteen days of real people using translated builds is worth more than fourteen days of them using English. So the app learned six languages in one pass, then thirteen more.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;code&gt;i18next&lt;/code&gt; + &lt;code&gt;react-i18next&lt;/code&gt; + a &lt;strong&gt;plural-rules polyfill&lt;/strong&gt; (the JS engine ships a stub &lt;code&gt;Intl&lt;/code&gt; on some platforms, and Arabic needs six plural categories), eight namespaces, static resources so the first frame already has copy. Language is &lt;strong&gt;per-user, not per-device&lt;/strong&gt; — the currency pattern — cached for the first frame, mirrored on the profile so it roams, cleared and restored on account switch; the Cognito &lt;code&gt;locale&lt;/code&gt; attribute is written at sign-up so emails can follow later. &lt;strong&gt;Arabic&lt;/strong&gt; flips the shell RTL with a native flag and a one-shot restart prompt, logical direction classes, and a font trick: the Arabic face is registered &lt;strong&gt;under the Latin font's names&lt;/strong&gt;, so every existing style re-faces with zero call-site changes. Two tiers — six human-reviewable launch languages, thirteen machine-translated — generated by a &lt;strong&gt;diff-only pipeline that forces structured output&lt;/strong&gt; because free-form JSON kept breaking on quotes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 33 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four pillars, all cloned from patterns the app already had
&lt;/h2&gt;

&lt;p&gt;The plan's insight was that the app &lt;em&gt;already contained&lt;/em&gt; every pattern i18n needed — a per-user preference (currency), a device-detected onboarding step (country), a searchable picker (voice language). i18n was the fourth instance of each, not a new discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The library layer&lt;/strong&gt; is &lt;code&gt;i18next&lt;/code&gt; + &lt;code&gt;react-i18next&lt;/code&gt; — pure JS, no dev-client rebuild — with the &lt;code&gt;intl-pluralrules&lt;/code&gt; polyfill. That polyfill matters more than it sounds: the Hermes engine ships a &lt;em&gt;real&lt;/em&gt; &lt;code&gt;Intl&lt;/code&gt; on some platforms and a &lt;strong&gt;stub&lt;/strong&gt; on others (the trap &lt;a href="https://dev.to/hokagedemehin/what-your-receipts-know-price-history-last-paid-trends-savings-231p"&gt;Part 16&lt;/a&gt; hit with thousands separators), and correct CLDR plural rules are essential once Arabic — with &lt;strong&gt;six&lt;/strong&gt; plural categories — is in the mix. Eight feature namespaces, &lt;strong&gt;static&lt;/strong&gt; resources bundled with the app so the very first frame already has copy (no flash of keys), and &lt;code&gt;dayjs&lt;/code&gt; switches locale alongside so dates translate with the words. 568 English keys at launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-user, not per-device — the shared-phone argument
&lt;/h2&gt;

&lt;p&gt;The theme preference is device-scoped, and the first instinct was to copy it. One question killed that: &lt;strong&gt;on a shared phone, user A picks Arabic and signs out — user B is now in Arabic with no idea how to fix it.&lt;/strong&gt; Language must follow the &lt;em&gt;person&lt;/em&gt;. So it copied the &lt;a href="https://dev.to/hokagedemehin/your-app-hardcodes-gbp-currency-as-a-first-class-preference-3eda"&gt;currency pattern&lt;/a&gt; instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UserProfile.locale&lt;/code&gt; is the durable source of truth, so the choice &lt;strong&gt;roams&lt;/strong&gt; to a user's other devices.&lt;/li&gt;
&lt;li&gt;A per-user AsyncStorage cache means the right language on the &lt;strong&gt;first frame&lt;/strong&gt;, before the profile query resolves.&lt;/li&gt;
&lt;li&gt;On account switch the language resets to &lt;em&gt;auto&lt;/em&gt; (device language) and is restored from the incoming profile — a returning user is in their language on first paint, a brand-new user is in their phone's, and nobody inherits a stranger's.&lt;/li&gt;
&lt;li&gt;Signed-out screens therefore run on the device language, which is the right default for an unknown human.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two extra lines at sign-up write Cognito's standard &lt;code&gt;locale&lt;/code&gt; attribute alongside. They looked like over-engineering at the time; they're what later let the verification and reset emails arrive in the user's language, because the email trigger fires &lt;em&gt;before&lt;/em&gt; any profile row exists and can only read the pool's own attributes. (That story, and its war story, is the next part.)&lt;/p&gt;

&lt;p&gt;First-run onboarding gained a language step &lt;em&gt;ahead&lt;/em&gt; of currency, pre-selecting the device language — guess, then confirm, as always.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4wokf9buf8jryfpwjru3.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4wokf9buf8jryfpwjru3.jpeg" alt="The language picker: every option in its own language, with Automatic following the device" width="590" height="1278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Arabic: the RTL flip, and a font trick I'm proud of
&lt;/h2&gt;

&lt;p&gt;Right-to-left was the reason Arabic was in the launch tier rather than the extended one: it's the language that finds every layout assumption. Three pieces of work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The flip itself.&lt;/strong&gt; RTL is a &lt;em&gt;native&lt;/em&gt; flag: allow it at init, keep the force-flag in step on every language apply, and accept that it takes effect on the &lt;em&gt;next launch&lt;/em&gt;. A one-shot restart prompt handles that — mounted &lt;strong&gt;inside&lt;/strong&gt; the onboarding gate on purpose, so a first-run Arabic choice finishes onboarding before it asks you to restart. Roughly 33 places where gap/flex-based layouts didn't already mirror had their physical direction classes replaced with logical ones (&lt;code&gt;ps&lt;/code&gt;/&lt;code&gt;pe&lt;/code&gt;, &lt;code&gt;ms&lt;/code&gt;/&lt;code&gt;me&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt;, &lt;code&gt;border-s&lt;/code&gt;/&lt;code&gt;border-e&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The font.&lt;/strong&gt; Nunito has no Arabic glyphs; Arabic text needs its own face, in the &lt;em&gt;same four weights&lt;/em&gt; (400/600/700/800), or bold headings render regular. Cairo was the one candidate with exactly that set (the obvious alternatives each lacked a weight). Then the trick: in RTL sessions, Cairo's four weights are registered &lt;strong&gt;under Nunito's names&lt;/strong&gt; in the font loader. Every &lt;code&gt;font-nunito*&lt;/code&gt; class and every raw &lt;code&gt;fontFamily: "Nunito_…"&lt;/code&gt; string — nineteen of them — re-faces automatically. &lt;strong&gt;Zero call-site changes.&lt;/strong&gt; Sometimes the best migration is lying to the font registry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six plurals.&lt;/strong&gt; Spanish, French and Italian carry their CLDR &lt;code&gt;_many&lt;/code&gt; forms; Arabic gets all six categories — and its &lt;code&gt;_zero&lt;/code&gt;/&lt;code&gt;_one&lt;/code&gt;/&lt;code&gt;_two&lt;/code&gt; forms deliberately &lt;strong&gt;drop the &lt;code&gt;{{count}}&lt;/code&gt; placeholder&lt;/strong&gt;, because in Arabic the number lives in the word itself (عنصر واحد — "one item" — contains no digit). A translation pipeline that blindly demands &lt;code&gt;{{count}}&lt;/code&gt; in every plural form produces Arabic that reads like a machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tiers, and a pipeline whose limiter is review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Launch tier&lt;/strong&gt; — Spanish, French, German, Italian, Arabic: languages where a human review is realistic, and where the plural/RTL work happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extended tier&lt;/strong&gt; — thirteen more (Portuguese, Dutch, Polish, Turkish, Russian, Ukrainian, Hindi, Indonesian, Vietnamese, Thai, Japanese, Korean, Simplified Chinese): machine-translated, labelled as such, generated by the same pipeline. Portuguese is deliberately &lt;strong&gt;pt-BR&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pipeline is a script that sends the &lt;em&gt;diff&lt;/em&gt; of English keys to a language model and writes the results per locale. Its one hard-won rule: &lt;strong&gt;force a structured tool call for the output.&lt;/strong&gt; Free-form JSON in the response kept breaking on quotes and embedded newlines — a translation of a toast with an apostrophe would take the whole locale file down. Structured output made generation zero-retry. A companion checker validates every locale against English for &lt;strong&gt;key parity&lt;/strong&gt; &lt;em&gt;and&lt;/em&gt; for the correct &lt;strong&gt;plural category set per language&lt;/strong&gt; — so Arabic can't ship with two forms and French with six.&lt;/p&gt;

&lt;p&gt;The observation from running it: &lt;em&gt;review, not tokens, is the limiter.&lt;/em&gt; Generating thirteen languages costs minutes; deciding whether the Vietnamese is any good is the actual work, which is why the tiers are honest about which languages a human has looked at.&lt;/p&gt;

&lt;p&gt;The one native-rebuild piece: iOS permission strings (camera, microphone, photos) are localised per language through the app config's locales, which bakes into the binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's deliberately not translated (yet)
&lt;/h2&gt;

&lt;p&gt;Category and aisle vocabulary — "Produce", "Dairy" — stays English-keyed internally. Those labels are display text &lt;em&gt;used as keys&lt;/em&gt; across the app and the parsing Lambdas; translating them touches the vocabulary the whole data model keys on, which is its own plan, not a string sweep. The voice and receipt parsers already tolerate non-English input; their internal vocabulary just stays anchored.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clone the patterns you already have.&lt;/strong&gt; Currency taught per-user prefs; onboarding taught guess-then-confirm; i18n was the fourth instance, not a new discipline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language follows the person, not the phone.&lt;/strong&gt; The shared-device question settles it in one sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the auth provider's locale attribute early&lt;/strong&gt; — it's the only thing an email trigger can read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arabic is the layout's audit&lt;/strong&gt; — RTL, six plurals, and placeholders that shouldn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register the fallback font under the primary's names&lt;/strong&gt; and skip the sweep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force structured output for generated files&lt;/strong&gt; — free-form JSON breaks on the first apostrophe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label your tiers honestly.&lt;/strong&gt; Machine-translated is fine; pretending it's reviewed isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 34: the emails caught up with the app — verification, reset and welcome messages in the user's language — and the deploy that failed every branch with "Invalid AttributeDataType", because a Cognito user pool's attribute schema is &lt;strong&gt;frozen at creation&lt;/strong&gt; and the sandbox never said so.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which language would audit your layout hardest — and does your plural handling survive a language where "one" and "two" are their own forms?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>i18n</category>
      <category>mobile</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Account deletion for real: immediate, guest round-trip, admin-initiated</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:43:19 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/account-deletion-for-real-immediate-guest-round-trip-admin-initiated-3k0n</link>
      <guid>https://dev.to/hokagedemehin/account-deletion-for-real-immediate-guest-round-trip-admin-initiated-3k0n</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/the-compliance-sprint-nobody-blogs-about-4i0h"&gt;Part 24&lt;/a&gt; shipped account deletion as a &lt;em&gt;request&lt;/em&gt; — a form, a queue, a runbook I'd follow by hand. It was honest and it was insufficient: the &lt;a href="https://dev.to/hokagedemehin/shipping-to-both-stores-the-release-runbook-29ma"&gt;release runbook&lt;/a&gt; ranked it the single most likely rejection, because Apple's 5.1.1(v) and Google Play both require deletion to be &lt;em&gt;completed&lt;/em&gt;, not filed.&lt;/p&gt;

&lt;p&gt;So deletion became real — in three lanes, each with a different threat model, sharing one Lambda's order of operations. This is the post where security decisions stop being abstract.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;strong&gt;In-app:&lt;/strong&gt; an argument-free authenticated mutation — the caller is whoever the API verified, so it can only ever delete &lt;em&gt;itself&lt;/em&gt;; the order &lt;strong&gt;S3 → DynamoDB → Cognito last&lt;/strong&gt; is the design, so any earlier failure leaves an account that can still sign in and retry; the S3 identity is derived from the caller's &lt;em&gt;own&lt;/em&gt; object keys, never a client-supplied value. &lt;strong&gt;Guest web:&lt;/strong&gt; a single-use confirm token, email dedupe, a confirm page that acts &lt;strong&gt;only on button click&lt;/strong&gt; (inbox scanners prefetch links), and a mutation that always answers &lt;code&gt;{ok: true}&lt;/code&gt; so the form can't probe which emails have accounts. &lt;strong&gt;Admin:&lt;/strong&gt; the same Lambda's twin with group rules (super-admins are un-deletable, never yourself), plus a repair path for half-deleted accounts — and &lt;strong&gt;anonymous activity rollups&lt;/strong&gt;, so erasing a user doesn't rewrite usage history.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 32 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lane 1: in-app, immediate — and the order &lt;em&gt;is&lt;/em&gt; the design
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;deleteAccount&lt;/code&gt; mutation takes &lt;strong&gt;no arguments&lt;/strong&gt;. That's the first security decision: the caller is whoever AppSync verified the token for, so the Lambda can only ever delete &lt;em&gt;the account making the call&lt;/em&gt;. There is no "which user?" parameter to tamper with, because there is no parameter.&lt;/p&gt;

&lt;p&gt;The second decision is the sequence. Deleting an account touches three stores, and the Lambda walks them in a specific order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;S3 objects&lt;/strong&gt; — avatar, receipt photos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB rows&lt;/strong&gt; — every table the user owns (names arriving as SSM paths, per the house convention; the list of tables to sweep is one explicit constant).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Cognito user — last.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reasoning: &lt;strong&gt;every earlier failure leaves an account that can still sign in and retry.&lt;/strong&gt; If S3 fails, nothing's gone. If DynamoDB fails halfway, the user still has credentials and can press the button again. Delete the Cognito user &lt;em&gt;first&lt;/em&gt; and any subsequent failure orphans data that can no longer be attributed to anyone — un-deletable exactly when you're obliged to delete it. &lt;a href="https://dev.to/hokagedemehin/the-compliance-sprint-nobody-blogs-about-4i0h"&gt;Part 24&lt;/a&gt; made this point about the manual runbook; the Lambda inherited it as a hard invariant.&lt;/p&gt;

&lt;p&gt;A third decision hides in step 1. Storage paths are keyed by the identity-pool ID, which is &lt;em&gt;not&lt;/em&gt; the Cognito sub. Rather than accepting that ID from the client (a value a hostile caller could point at someone else's prefix), the Lambda &lt;strong&gt;derives it from the caller's own object keys&lt;/strong&gt; — the avatar key and receipt image keys already stored on their rows. If you own the row, the row tells us your prefix.&lt;/p&gt;

&lt;p&gt;When it's done, one thing is deliberately &lt;em&gt;left behind&lt;/em&gt;: a &lt;code&gt;COMPLETED&lt;/code&gt; deletion-request row — proof of fulfilment, with no user data on it, for the ninety days the privacy policy says records are kept.&lt;/p&gt;

&lt;p&gt;On the client: a type-DELETE confirmation, immediate execution, sign-out after. And one honesty detail — the Lambda &lt;strong&gt;deliberately outlives the API gateway's timeout&lt;/strong&gt; (a full sweep can take longer than the request window), so a "failed" call is reported as &lt;em&gt;"outcome unknown — check by signing in"&lt;/em&gt;, never &lt;em&gt;"nothing happened."&lt;/em&gt; The one thing worse than a slow deletion is telling someone it didn't happen when it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lane 2: the guest round-trip that can't leak
&lt;/h2&gt;

&lt;p&gt;Google Play requires a public web page where &lt;em&gt;anyone&lt;/em&gt; — including someone who can't sign in — can request deletion. A guest form is an attack surface by definition, and the original version (a guest-writable model, verify-by-reply) was tightened into a proper flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The form calls a mutation; direct guest writes to the model are revoked.&lt;/strong&gt; One writer, so every request passes through the same guards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One email per address per 24 hours.&lt;/strong&gt; The form can't be used to flood an inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single-use confirm token&lt;/strong&gt; lands in a branded email with a confirm link (with a reply-to-confirm fallback when no site origin is configured).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The confirm page acts only on button click.&lt;/strong&gt; This one matters more than it looks: corporate inboxes and security scanners &lt;em&gt;prefetch&lt;/em&gt; every link in an email. A link that confirms on GET would confirm deletions that nobody clicked. The page loads inert and deletes only on an explicit press.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mutation always answers &lt;code&gt;{ok: true}&lt;/code&gt;.&lt;/strong&gt; Whether the address has an account or not, the response is identical — otherwise the public form becomes an &lt;strong&gt;email oracle&lt;/strong&gt;: a way for anyone to test whether an address is registered. The truth goes to the support inbox, which is notified separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Confirmed web requests take the documented 30-day path in the privacy policy; in-app deletion is immediate. Two doors, two timelines, both written down where users can read them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lane 3: admin-initiated, without rewriting history
&lt;/h2&gt;

&lt;p&gt;Support sometimes needs to delete an account on someone's behalf. &lt;code&gt;adminDeleteUser&lt;/code&gt; is the deletion Lambda's twin — same S3 → DynamoDB → Cognito-last order — with the target named by sub and a set of rules the handler enforces against the caller's &lt;em&gt;verified&lt;/em&gt; groups (not anything the client claims):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;super-admin&lt;/strong&gt; can never be a target — refused outright.&lt;/li&gt;
&lt;li&gt;Deleting an &lt;strong&gt;admin&lt;/strong&gt; requires a super-admin caller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never yourself&lt;/strong&gt; — use the app's own door.&lt;/li&gt;
&lt;li&gt;A sub with rows but &lt;strong&gt;no Cognito user still erases&lt;/strong&gt; — the repair path for half-deleted accounts from the manual era.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the subtle design that makes admin deletion safe for &lt;em&gt;analytics&lt;/em&gt;: an &lt;code&gt;ActivityRollup&lt;/code&gt; model of anonymous per-day, per-action counters. As the Lambda erases a user's activity rows, it increments the rollups — so deleting an account &lt;strong&gt;doesn't rewrite usage history&lt;/strong&gt;. The rollups carry &lt;strong&gt;no &lt;code&gt;userId&lt;/code&gt; by design&lt;/strong&gt;, which is precisely what lets them survive the erasure they record. They replicate to dev as structured passthrough and are admin-read-only. A deleted user disappears; the fact that &lt;em&gt;someone&lt;/em&gt; scanned a receipt on that Tuesday does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legal copy had to get honest too
&lt;/h2&gt;

&lt;p&gt;Making deletion real forced a re-read of the privacy policy, and one paragraph turned out to be &lt;strong&gt;false&lt;/strong&gt;. The barcode feature's product lookups run server-side — but the review sheet loads product &lt;em&gt;photos&lt;/em&gt; straight from the third-party database's servers, which means they &lt;em&gt;do&lt;/em&gt; see the device's IP at that moment. The drafted "they never see your IP" wording was wrong and was rewritten to disclose all four databases, the server-side lookup, &lt;em&gt;and&lt;/em&gt; the photo exception. Deletion got its own rewritten section (immediate in-app; email-verified 30-day web path; records kept 90 days), and each legal document got its own updated-date so amending one no longer restamps the other.&lt;/p&gt;

&lt;p&gt;Compliance work has a way of auditing your own claims. It's worth doing before someone else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity comes from the token, never a parameter.&lt;/strong&gt; An argument-free mutation can only delete its caller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order destructive operations so every failure is retryable&lt;/strong&gt; — the step that destroys the ability to retry goes last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive cross-store identifiers from data the caller already owns&lt;/strong&gt;, never from the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm on click, never on load.&lt;/strong&gt; Link scanners prefetch; a GET must be inert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public forms must not be oracles.&lt;/strong&gt; Identical responses whether or not the account exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erase the person, keep the count.&lt;/strong&gt; Anonymous rollups let deletion and analytics coexist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit the policy while you build the feature&lt;/strong&gt; — mine contained a sentence that wasn't true.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 33: the app learned to speak &lt;strong&gt;nineteen languages&lt;/strong&gt; — a two-tier translation model (human-reviewed launch tier, machine-translated extended tier), a diff-only translation pipeline, and the day the shell flipped to right-to-left.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What does your delete-account flow do if the Cognito (or auth-provider) call succeeds but the data delete fails? If the answer is "orphans," swap the order.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>serverless</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Moving a live backend to a fresh AWS account</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:41:14 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/moving-a-live-backend-to-a-fresh-aws-account-1m99</link>
      <guid>https://dev.to/hokagedemehin/moving-a-live-backend-to-a-fresh-aws-account-1m99</guid>
      <description>&lt;p&gt;Two weeks before the store launch, the entire backend moved to a &lt;strong&gt;brand-new AWS account&lt;/strong&gt;. Not a disaster-recovery drill — a deliberate reorganisation: the old account had grown up as a personal sandbox, and a fresh one with proper identity management was where a product with real users belonged. The closed-test deployment kept running in the old account until cutover.&lt;/p&gt;

&lt;p&gt;It sounds terrifying. It was mostly boring, which is the point of this post: the conventions built in the earliest parts of this series are what made it boring, and the two things that did bite had nothing to do with the data.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A fresh account is &lt;em&gt;empty&lt;/em&gt;, which makes the scariest migration step (renaming every table) &lt;strong&gt;free&lt;/strong&gt;: the env-scoped naming code simply deploys clean on day one, so the copy-old-to-new script written for an in-place rename went unused. What can't move: &lt;strong&gt;Cognito users&lt;/strong&gt; — passwords aren't exportable, and re-signup means new ids anyway, so user-keyed data resets by design. What must change: &lt;strong&gt;every client build&lt;/strong&gt;, because new pools and endpoints mean new outputs. What you forget: the old account's Hosting &lt;strong&gt;webhook keeps building&lt;/strong&gt; every push, and the new Hosting app has &lt;strong&gt;none of your env vars&lt;/strong&gt;. Plus the small mechanics that made multi-project accounts and warm Lambdas behave.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 31 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that didn't need to happen
&lt;/h2&gt;

&lt;p&gt;The plan I'd been carrying was an &lt;strong&gt;in-place table rename&lt;/strong&gt;: adding a project prefix to every DynamoDB table (&lt;code&gt;cannycart-{Model}-{env}&lt;/code&gt;) so multiple Amplify apps could share one account. Renames &lt;em&gt;replace&lt;/em&gt; resources in this stack, so in a live account that meant a copy script — walk every old table, write into the new one, cut over in a quiet hour, and pray the orphans get cleaned up. I'd written the script.&lt;/p&gt;

&lt;p&gt;Then the decision to move accounts arrived, and the whole problem dissolved: &lt;strong&gt;a fresh account has no old tables.&lt;/strong&gt; The renaming code — the same &lt;code&gt;backend.ts&lt;/code&gt; override from &lt;a href="https://dev.to/hokagedemehin/my-main-branch-wouldnt-deploy-cloudformation-forensics-on-amplify-gen-2-380d"&gt;the resource-naming days&lt;/a&gt; — just deploys clean from the first &lt;code&gt;sandbox&lt;/code&gt; run, tables arriving with their new names natively. No copy, no cutover window, no orphans. The migration script stays in the repo, harmless and unused, in case the old account ever needs the in-place path.&lt;/p&gt;

&lt;p&gt;The transferable lesson: &lt;strong&gt;sometimes the cleanest migration is a new environment.&lt;/strong&gt; When the destination is empty, "rename" becomes "name," and a week of risk becomes a config change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can't move: the users
&lt;/h2&gt;

&lt;p&gt;Cognito users don't migrate. Passwords aren't exportable — by design — and even a bulk re-create would issue every user a &lt;strong&gt;new &lt;code&gt;sub&lt;/code&gt;&lt;/strong&gt;, the id that keys every profile, list, item and receipt in the app. So user-keyed data &lt;strong&gt;resets by design&lt;/strong&gt; with the move: closed testers re-register, and their data starts fresh.&lt;/p&gt;

&lt;p&gt;That was acceptable &lt;em&gt;because of when it happened&lt;/em&gt; — closed test, a dozen testers, no store customers yet. Two weeks later it wouldn't have been. If your app is already live, an account move is a different, much harder project (identity-first migration with an activation flow), and the time to move is &lt;em&gt;before&lt;/em&gt; the first real user. The only user-independent data worth carrying was the &lt;a href="https://dev.to/hokagedemehin/barcode-lookup-a-provider-chain-with-an-llm-as-the-normaliser-33ha"&gt;shared &lt;code&gt;Product&lt;/code&gt; barcode cache&lt;/a&gt; — global facts, no &lt;code&gt;userId&lt;/code&gt;, optional copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What must change: every client build
&lt;/h2&gt;

&lt;p&gt;The opposite of the rename-only scenario, where clients wouldn't have noticed: &lt;strong&gt;a new account means new Cognito pools and new AppSync endpoints&lt;/strong&gt;, so every per-environment outputs file changes (&lt;a href="https://dev.to/hokagedemehin/one-backend-three-environments-the-4-kib-limit-that-redesigned-my-eas-setup-35cl"&gt;Part 2&lt;/a&gt;'s mechanism), which means new EAS builds and new store submissions once the new production backend is live. Anyone with an old build installed is pointed at a backend that's about to be turned off. The move therefore &lt;em&gt;had&lt;/em&gt; to land before the first store release, not after — the timing wasn't a coincidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanics that made it boring
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The project slug in every table name&lt;/strong&gt; — &lt;code&gt;cannycart-{Model}-{env}&lt;/code&gt; — is what lets multiple Amplify apps share the account: table names are account-and-region unique, and the IAM wildcards became &lt;code&gt;table/cannycart-*&lt;/code&gt; (deletion Lambda, replicator, post-confirmation writes). The &lt;a href="https://dev.to/hokagedemehin/your-prod-data-faked-one-way-replication-with-deterministic-pii-masking-58cl"&gt;replicator&lt;/a&gt; strips the prefix when deriving model names — its masking map keys on bare names — and &lt;em&gt;ignores other projects' streams&lt;/em&gt; entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSM table-name caches got a 5-minute TTL.&lt;/strong&gt; Lambdas read table names from Parameter Store and cache them; a warm Lambda would otherwise keep writing to an old name after a rename indefinitely. A short TTL means renames reach warm functions within minutes, no redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AWS profile became overridable&lt;/strong&gt; (&lt;code&gt;${AWS_PROFILE:-…}&lt;/code&gt;) instead of hardcoded across a dozen scripts — the kind of thing you fix once, during the move, so the &lt;em&gt;next&lt;/em&gt; move is a one-line change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One IAM grant got tighter while we were in there:&lt;/strong&gt; the post-confirmation trigger's write permission narrowed from &lt;code&gt;table/*&lt;/code&gt; to the one profile table it actually writes. Migrations are a good moment to audit the wildcards you accepted while prototyping.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two things that bit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The old account kept building.&lt;/strong&gt; The old Amplify Hosting app was still connected to the GitHub repo via its webhook — so every push to &lt;code&gt;dev&lt;/code&gt; triggered a &lt;em&gt;stale build in the old account&lt;/em&gt; for weeks after the move. Harmless, confusing, and quietly costing money: "the deploy failed" alerts from a backend nobody used anymore. Disconnect the old Hosting app &lt;em&gt;as part of&lt;/em&gt; the move, not as cleanup later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Env vars don't come along.&lt;/strong&gt; The new Hosting app had none of the old one's environment variables — the AI parsing key, the email sender address. Nothing fails loudly: the backends deploy fine, and every AI-powered feature is simply dead on dev and prod until the vars are set in the console &lt;strong&gt;and both branches rebuild&lt;/strong&gt;. Secrets and config live outside the repo by design, which means a new account starts with an empty set — put "re-enter every env var" in the runbook as a step, not a discovery.&lt;/p&gt;

&lt;p&gt;And one external clock: transactional-email production access doesn't transfer either — the sending identity and its reputation belong to the old account. That restart is part of why app emails moved to a dedicated provider with a verified domain (a story the email parts tell).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An empty destination makes renames free.&lt;/strong&gt; If a migration's hardest step is renaming, ask whether the answer is a new environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move before the first real user, or don't.&lt;/strong&gt; Cognito users don't migrate; the ids that key your data change with them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New account, new builds.&lt;/strong&gt; Anything that bakes endpoints into a client must ship again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-TTL your config caches.&lt;/strong&gt; Warm Lambdas hold on to old names longer than you'd think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disconnect the old webhook, re-enter every env var, and audit your wildcards&lt;/strong&gt; — the three steps every account-move runbook should list explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 32: account deletion done properly — an in-app Lambda that erases server-side in the &lt;em&gt;right order&lt;/em&gt;, a guest email round-trip that can't become an email oracle, and admin-initiated deletion with anonymous rollups.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you ever moved a backend between accounts? What was the thing that kept running in the old one after you thought you were done?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>serverless</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Shipping to both stores: the release runbook</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:38:53 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/shipping-to-both-stores-the-release-runbook-29ma</link>
      <guid>https://dev.to/hokagedemehin/shipping-to-both-stores-the-release-runbook-29ma</guid>
      <description>&lt;p&gt;On 27 August, Canny Cart went live in both stores — the &lt;a href="https://apps.apple.com/gb/app/canny-cart/id6799479952" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=com.cannycart.app" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; — as v1.0.1. Twenty-nine parts of building in public and the actual &lt;em&gt;shipping&lt;/em&gt; turned out to be its own project, with its own runbook: ten numbered documents covering accounts, blockers, credentials, listings, privacy declarations, ratings, testing tracks, submission, and launch day.&lt;/p&gt;

&lt;p&gt;This post is that runbook, compressed to what I'd tell someone releasing a solo app for the first time. The headline: &lt;strong&gt;the two things that decided the launch date weren't code&lt;/strong&gt;, the highest-consequence failure mode was &lt;em&gt;silent&lt;/em&gt;, and the one rejection that actually arrived was about a button.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Start Google Play's &lt;strong&gt;closed test first&lt;/strong&gt;: new personal accounts need 12 testers continuously opted in for 14 days before you can even &lt;em&gt;apply&lt;/em&gt; for production, and the clock starts when the twelfth joins. Get a &lt;strong&gt;domain&lt;/strong&gt; early — Play won't roll out to &lt;em&gt;any&lt;/em&gt; track without a Data Safety form, which needs a privacy-policy URL. Fix the blockers ranked by rejection probability: real in-app &lt;strong&gt;account deletion&lt;/strong&gt; (Apple 5.1.1(v)), &lt;strong&gt;email deliverability&lt;/strong&gt; (a reviewer's test account must receive its verification code), and third-party data disclosure. Guard the silent killer: a missing production outputs file makes the build &lt;strong&gt;ship pointed at your sandbox&lt;/strong&gt; without a single error. Expect one rejection anyway — mine was 5.1.1(iv): a camera permission gate with an "Allow" button. And &lt;strong&gt;approved ≠ released&lt;/strong&gt;: phased/staged rollout, manual release, so approval doesn't put you live at 3 a.m.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 30 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things that decided the date (neither was code)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Google Play's closed test.&lt;/strong&gt; New personal developer accounts must run a closed test with &lt;strong&gt;at least 12 testers, continuously opted in, for 14 days&lt;/strong&gt; — before you can even apply for production access. The 14 days start when you &lt;em&gt;have&lt;/em&gt; 12 testers, not when you create the track; drop below twelve and the clock can reset. Nothing else in the release takes as long, so the runbook's first instruction is: &lt;strong&gt;start this first, today&lt;/strong&gt;, and let it run in the background while you do everything else. (You'll also need twelve friends who'll actually install a test build. Budget the social capital.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A domain.&lt;/strong&gt; I didn't have one. It gates far more than a pretty listing: Play won't roll out to &lt;em&gt;any&lt;/em&gt; track — closed testing included — without a completed Data Safety form, and that form needs a &lt;strong&gt;privacy-policy URL&lt;/strong&gt; that resolves. The &lt;a href="https://dev.to/hokagedemehin/the-compliance-sprint-nobody-blogs-about-4i0h"&gt;compliance sprint&lt;/a&gt; had built the legal pages; without a domain they weren't &lt;em&gt;anywhere&lt;/em&gt;. Buy the domain before you think you need it.&lt;/p&gt;

&lt;p&gt;Everything else, in the runbook's words, "is paperwork, assets and a build."&lt;/p&gt;

&lt;h2&gt;
  
  
  The code blockers, ranked by rejection probability
&lt;/h2&gt;

&lt;p&gt;The blockers document ordered the repo's problems by how likely each was to get the app rejected. Three mattered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔴 Account deletion was a request, not a deletion.&lt;/strong&gt; The app filed a &lt;code&gt;DeletionRequest&lt;/code&gt; for me to fulfil by hand — and Apple's 5.1.1(v) expects deletion &lt;em&gt;initiated and completed&lt;/em&gt; from the app. "Email us and we'll get to it" is the classic rejection. The cheapest fix was to actually automate it: a server-side deletion Lambda (verify the caller → S3 objects → DynamoDB rows → the Cognito user, last). A day of work that removed the risk permanently; it gets its own part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔴 Email deliverability.&lt;/strong&gt; The transactional-email service was still in its sandbox mode, where mail only reaches pre-verified addresses. The consequence isn't cosmetic: &lt;strong&gt;a reviewer creating a test account would never receive their verification code&lt;/strong&gt;, and the sign-up flow needs that code. This alone can fail review. Auth emails were confirmed on Cognito's own default sender (unaffected), and app emails moved to a provider with a verified domain — no code path in the review flow depended on a sandboxed sender.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🟠 An undisclosed third party.&lt;/strong&gt; Barcode lookups call the Open Food Facts databases — a data flow to a third party that the privacy declarations had to name. Store data-safety forms are an inventory of every external call your app makes; the &lt;a href="https://dev.to/hokagedemehin/barcode-lookup-a-provider-chain-with-an-llm-as-the-normaliser-33ha"&gt;provider chain&lt;/a&gt; had quietly added one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The highest-consequence silent failure
&lt;/h2&gt;

&lt;p&gt;The mobile app selects its backend config per build profile from committed per-environment files, with a fallback to the root file when the profile's file is missing (&lt;a href="https://dev.to/hokagedemehin/one-backend-three-environments-the-4-kib-limit-that-redesigned-my-eas-setup-35cl"&gt;Part 2&lt;/a&gt;'s mechanism). Robust for development. &lt;strong&gt;Catastrophic for a first release:&lt;/strong&gt; &lt;code&gt;amplify_outputs.production.json&lt;/code&gt; didn't exist yet — because the production backend hadn't been deployed — and the selector would have silently fallen back to the root file, which is generated from my &lt;em&gt;personal sandbox&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A production build made that day would have shipped to both stores pointed at my dev backend, with zero errors anywhere in the pipeline. The runbook flags it as the highest-consequence silent failure in the whole release, and the pre-build checklist now includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; amplify_outputs.production.json
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'"user_pool_id": "[^"]*"'&lt;/span&gt; amplify_outputs.production.json   &lt;span class="c"&gt;# NOT your sandbox pool&lt;/span&gt;
npm ci &lt;span class="nt"&gt;--dry-run&lt;/span&gt;                                                     &lt;span class="c"&gt;# EAS installs with npm ci&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy &lt;code&gt;main&lt;/code&gt;, generate the production outputs, commit, &lt;em&gt;then&lt;/em&gt; build. Fallbacks that are convenient in dev are exactly the ones that hurt in release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejection that came anyway
&lt;/h2&gt;

&lt;p&gt;Build 6 of 1.0.1 was rejected under &lt;strong&gt;5.1.1(iv)&lt;/strong&gt; — on the barcode scanner's camera permission gate. The scanner opened with an explanation screen: an "Allow camera" button and a "Not now" button that let you delay the request. Both are violations, and the rejection letter itself explained why: the permission button's wording must be neutral (not "Allow"), and the flow must not let the user &lt;em&gt;defer&lt;/em&gt; the system prompt.&lt;/p&gt;

&lt;p&gt;The fix followed the letter's own recommendation. &lt;strong&gt;Tapping "Barcode" &lt;em&gt;is&lt;/em&gt; the intent&lt;/strong&gt;, so the system prompt fires the moment the screen opens, with the explanation text as a &lt;em&gt;backdrop&lt;/em&gt; rather than a gate in front. A denied state shows "Continue" (re-askable, Android) or "Open Settings" (iOS), and leaving is a standard ✕ — navigation chrome, not a permission choice. Then a sweep of every other permission surface — voice, receipt camera, photo-to-list, avatar, notifications — confirmed they already called the system prompt directly. The resubmission passed.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;permissions are UX that reviewers grade.&lt;/strong&gt; Your carefully explained pre-prompt is, to Apple, a gate between the user and the system dialog. Ask in context, ask immediately, let the OS do the asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approved is not released
&lt;/h2&gt;

&lt;p&gt;Two runbook rules for launch day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phased / staged rollout, always.&lt;/strong&gt; Apple's Phased Release spreads updates over seven days (1% → 100%) and can be paused; Play's staged rollout goes 20% → 50% → 100% and can be halted — but a 100% release can't be cleanly unpublished. For a backend that's never been load-tested by strangers, this is the only sane default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual release, not automatic&lt;/strong&gt; — so approval doesn't put you live at 3 a.m. while you're asleep. Approval is a notification; release is a decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one thing that &lt;em&gt;turns on&lt;/em&gt; at production that nobody expects: the &lt;a href="https://dev.to/hokagedemehin/your-prod-data-faked-one-way-replication-with-deterministic-pii-masking-58cl"&gt;prod → dev replicator&lt;/a&gt; is wired only in the production deployment, so it had been dormant for a month. On the first real user it woke up and began mirroring masked writes into dev — exactly as designed, and still slightly startling to watch.&lt;/p&gt;

&lt;p&gt;Only after both listings existed did the website's store badges become real links; they'd shipped as disabled placeholders until then, per a note in the repo's own instructions — "until the listings exist."&lt;/p&gt;

&lt;h2&gt;
  
  
  After: the cadence
&lt;/h2&gt;

&lt;p&gt;v1.0.1 was live on 27 August. Since then: &lt;strong&gt;1.0.2&lt;/strong&gt; (the receipt-linking schema), &lt;strong&gt;1.0.3&lt;/strong&gt; (stored sessions survive a failed token refresh; dismissed announcements survive sign-out), and &lt;strong&gt;1.0.4&lt;/strong&gt; (save a receipt from Expenses and link it on the full page) is built and heading to the stores. Each one runs the same pre-build checklist, the same &lt;code&gt;npm ci --dry-run&lt;/code&gt;, the same staged rollout. The runbook didn't retire at launch; it became the release process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start the slowest external clock first.&lt;/strong&gt; Twelve testers for fourteen days is the launch date; everything else fits inside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buy the domain early.&lt;/strong&gt; It gates privacy forms, which gate every store track, which gate the testers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rank blockers by rejection probability&lt;/strong&gt;, not by how annoying they are to fix. Deletion, deliverability, disclosure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hunt the silent fallback.&lt;/strong&gt; The scariest bug in a release is the one that produces a working build pointed at the wrong world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions are graded UX.&lt;/strong&gt; No "Allow" buttons, no "Not now" — the OS dialog, in context, immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approved ≠ released.&lt;/strong&gt; Manual release, phased rollout, and sleep.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 31: the backend moved to a &lt;strong&gt;fresh AWS account&lt;/strong&gt; mid-flight — and the env-scoped naming convention from the earliest days of the series is what let a staged table rename deploy clean.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What was the non-code thing that actually decided your launch date? I'd bet it wasn't in your roadmap either.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>expo</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Scan now, link later: receipts that wait for the list</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:35:55 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/scan-now-link-later-receipts-that-wait-for-the-list-171e</link>
      <guid>https://dev.to/hokagedemehin/scan-now-link-later-receipts-that-wait-for-the-list-171e</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;Receipt scanning&lt;/a&gt; worked, and it felt like homework. You'd come home with the receipt, open the Expenses tab, scan — and the app would immediately want a &lt;em&gt;decision&lt;/em&gt;: review every line, confirm every match against the list, log it. If you were unpacking shopping with one hand, the honest answer was "not now" — and "not now" meant the receipt never got scanned at all.&lt;/p&gt;

&lt;p&gt;The fix inverted the order of operations: &lt;strong&gt;save the receipt first, ask questions later.&lt;/strong&gt; It sounds like a UI tweak. It turned into a small data-model design with one immutability rule to protect.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A receipt button on the Shopping tab saves the receipt &lt;em&gt;before&lt;/em&gt; asking "Link now / Later." &lt;code&gt;Receipt.reconciledAt === null&lt;/code&gt; means &lt;em&gt;pending&lt;/em&gt;, which drives a strip and badge on the list. Lines are stored with &lt;strong&gt;provisional&lt;/strong&gt; match ids and &lt;code&gt;addedToList: false&lt;/code&gt;, so an unlinked receipt's money shows as the Expenses off-list caption, never the headline. Linking &lt;strong&gt;re-verifies every match against the list as it is *then&lt;/strong&gt;* (a hand-ticked item degrades to "still to buy," never a double tick), applies ticks with the receipt's printed date, enriches the snapshot &lt;strong&gt;once&lt;/strong&gt;, and stamps &lt;code&gt;reconciledAt&lt;/code&gt;. Matching became model-first with a conservative fallback ladder — and deliberately no edit distance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 29 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of operations was the bug
&lt;/h2&gt;

&lt;p&gt;The original flow coupled two jobs that don't want to be coupled: &lt;em&gt;recording&lt;/em&gt; the receipt (a fact about the world — you bought these things for this much) and &lt;em&gt;reconciling&lt;/em&gt; it with the shopping list (ticking items, applying prices, adding the strays). Recording takes a photo. Reconciling takes attention. Forcing them into one sitting made the cheap one hostage to the expensive one.&lt;/p&gt;

&lt;p&gt;So the Shopping tab grew a receipt button (next to History): photograph → parsed → &lt;strong&gt;saved immediately&lt;/strong&gt; → &lt;em&gt;then&lt;/em&gt; a two-button question: &lt;strong&gt;Link now&lt;/strong&gt; or &lt;strong&gt;Later&lt;/strong&gt;. Later is a real answer. The receipt exists either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;reconciledAt: null&lt;/code&gt; is a state, not an absence
&lt;/h2&gt;

&lt;p&gt;The whole feature hangs on one nullable timestamp. A receipt with &lt;code&gt;reconciledAt === null&lt;/code&gt; is &lt;strong&gt;pending&lt;/strong&gt;: the list shows a strip ("1 receipt waiting to be linked") and a badge on the receipt button. Linking stamps the timestamp. Dismissing the strip stamps it too — &lt;em&gt;with no links&lt;/em&gt; — so "I'll never link this one" is also a first-class outcome rather than a permanent nag.&lt;/p&gt;

&lt;p&gt;Receipts scanned from the Expenses tab are born linked: the review sheet's primary button records and reconciles in one go, exactly as before. Nothing changed for the people who liked the old flow; a second, cheaper door opened for everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provisional matches, and money that stays out of the headline
&lt;/h2&gt;

&lt;p&gt;A pending receipt's lines are stored with &lt;strong&gt;provisional&lt;/strong&gt; match ids (the parser's best guess at which list item each line corresponds to) and &lt;strong&gt;&lt;code&gt;addedToList: false&lt;/code&gt;&lt;/strong&gt; on every line. That second flag is what keeps the money honest. &lt;a href="https://dev.to/hokagedemehin/budgets-live-on-months-not-lists-the-period-first-expenses-rework-38i1"&gt;Part 19&lt;/a&gt; established the rule: the Expenses headline sums the &lt;em&gt;purchase ledger&lt;/em&gt; — items actually bought through a list — and receipt lines that never touched a list are a separate caption. An unlinked receipt is precisely that: real spending, not yet attributed to any list or budget. So its money shows in the caption and &lt;strong&gt;never in the headline&lt;/strong&gt;, until linking moves the lines into the ledger. No double counting is possible, because a line is either in the ledger (linked) or in the caption (pending) — never both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linking re-verifies against the list &lt;em&gt;as it is then&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes "later" safe. Time passes between scan and link. The list changes: you tick things by hand, delete something, add more. A provisional match made at scan time might point at an item that's since been ticked, removed, or claimed by another line.&lt;/p&gt;

&lt;p&gt;So the link page trusts nothing provisional. It &lt;strong&gt;re-verifies every match against the list as it exists at link time&lt;/strong&gt;: the item must exist, be unchecked, and be unclaimed by any other line. The important degradation rule: an item you've &lt;em&gt;already ticked by hand&lt;/em&gt; degrades to "still to buy" for that line — it is &lt;strong&gt;never double-ticked&lt;/strong&gt;. A receipt arriving late must not rewrite what your hands already did.&lt;/p&gt;

&lt;p&gt;Then the ticks are applied with the receipt's &lt;strong&gt;printed purchase date&lt;/strong&gt; (the same midday-local stamping as &lt;a href="https://dev.to/hokagedemehin/the-purchase-ledger-when-did-you-actually-buy-that-5age"&gt;Part 18&lt;/a&gt;'s ledger), so a receipt linked on Friday for a shop on Tuesday lands in Tuesday's week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-time enrichment, and the immutability rule
&lt;/h2&gt;

&lt;p&gt;Part 14 made a promise: the receipt's line snapshot is &lt;strong&gt;immutable&lt;/strong&gt; — a record of what was read, independent of the live items that keep changing. Linking looks like it violates that: it writes match ids and &lt;code&gt;addedToList&lt;/code&gt; back into the lines.&lt;/p&gt;

&lt;p&gt;The resolution is a rule with a boundary: enrichment happens &lt;strong&gt;exactly once&lt;/strong&gt;, at the moment &lt;code&gt;reconciledAt&lt;/code&gt; is stamped, and never again. Before linking, the snapshot is "what the parser read plus provisional guesses"; after linking, it's "what the parser read plus what actually happened." The record graduates from provisional to final one time and then freezes. A snapshot that can be enriched once, at a known transition, is still a snapshot; one that can be edited whenever is a mutable table wearing a costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching: model-first, fallback ladder, no edit distance
&lt;/h2&gt;

&lt;p&gt;The reconciliation also changed &lt;em&gt;how&lt;/em&gt; lines find their items. The parse mutation now takes the list's unchecked &lt;code&gt;{id, name}&lt;/code&gt; candidates as an optional argument and returns a &lt;code&gt;matchId&lt;/code&gt; per line — told, as always, to &lt;strong&gt;prefer null over a guess&lt;/strong&gt;. The client re-verifies every id (exists, unchecked, unclaimed), then falls back to a &lt;strong&gt;conservative ladder&lt;/strong&gt; for the rest: barcode → exact normalised name → token containment. Deliberately &lt;strong&gt;no edit-distance matching&lt;/strong&gt;: fuzzy string similarity is how "Cream" matches "Ice cream" and a wrong tick lands on a real purchase. A conservative matcher that leaves a line unmatched costs one tap; a clever one that mismatches costs trust.&lt;/p&gt;

&lt;p&gt;And a UX lesson paid for in full: the first version let you correct a match from a picker sheet stacked on top of the review sheet. &lt;strong&gt;A picker over a sheet broke the flow&lt;/strong&gt; — two modal layers, two dismiss gestures, and confusion about which one you were in. Matches now render as &lt;strong&gt;read-only text&lt;/strong&gt; in the review sheet, and &lt;em&gt;all&lt;/em&gt; corrections live on the link page, which became the one correction surface: three entry points, one-to-one claims, and picking a new item visibly un-claims the previous one. One place to fix things beats a fix-it affordance on every surface.&lt;/p&gt;

&lt;p&gt;The link page itself moved into a shared component mounted as thin routes in &lt;strong&gt;both&lt;/strong&gt; the Shopping and Expenses stacks — the Part 14 pattern again — so opening it from Expenses no longer jumps you to a different tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decouple recording from reconciling.&lt;/strong&gt; The photo is cheap; the decisions are expensive. Never make the cheap step wait for the expensive one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A nullable timestamp is a state machine.&lt;/strong&gt; &lt;code&gt;reconciledAt: null&lt;/code&gt; drove a strip, a badge, a caption rule and a dismissal path — one field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provisional data must stay out of totals&lt;/strong&gt; until it's verified — the &lt;code&gt;addedToList&lt;/code&gt; flag is the honesty gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-verify against the present.&lt;/strong&gt; Anything decided at scan time is a guess by link time; your hands' work wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich once, then freeze.&lt;/strong&gt; Immutability with a single known transition is still immutability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No edit distance in money matching.&lt;/strong&gt; Conservative matchers cost taps; fuzzy ones cost trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One correction surface.&lt;/strong&gt; A picker over a sheet was two too many layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 30 is the one this whole series has been building toward: &lt;strong&gt;shipping to both stores&lt;/strong&gt; — the runbook, the credentials, the privacy declarations, the review rejection, and what v1.0.1 through v1.0.4 taught about releasing as one person.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where in your app does a cheap action get held hostage by an expensive decision — and what would "save first, ask later" look like there?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>aws</category>
      <category>architecture</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Branded Cognito emails: the customMessage trigger in 20 minutes</title>
      <dc:creator>Ibukun Demehin</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:33:40 +0000</pubDate>
      <link>https://dev.to/hokagedemehin/branded-cognito-emails-the-custommessage-trigger-in-20-minutes-4c3b</link>
      <guid>https://dev.to/hokagedemehin/branded-cognito-emails-the-custommessage-trigger-in-20-minutes-4c3b</guid>
      <description>&lt;p&gt;Cognito's default emails are a small embarrassment: plain text, no name, a bare code — the kind of message a security-aware user treats as a phishing test. For a shopping app trying to look like something you'd trust with your receipts, that's the first impression at sign-up.&lt;/p&gt;

&lt;p&gt;The fix is a single Lambda on the user pool's &lt;code&gt;customMessage&lt;/code&gt; trigger. The wiring is twenty minutes. The interesting parts are the three ways it silently fails, and the one line that keeps the deploy from tying itself in a knot.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Define a &lt;code&gt;customMessage&lt;/code&gt; trigger function with &lt;strong&gt;&lt;code&gt;resourceGroupName: "auth"&lt;/code&gt;&lt;/strong&gt; so it deploys inside the auth stack — a trigger living in the shared function stack is an auth ↔ function circular dependency. In the handler, the &lt;strong&gt;code parameter must appear in the body&lt;/strong&gt; or Cognito rejects the message and &lt;em&gt;sends nothing&lt;/em&gt;. Write email HTML like it's 2009 — tables, inline styles, no webfonts (your brand font can't ride along; the colours carry the brand). And on the invite trigger, &lt;code&gt;codeParameter&lt;/code&gt; isn't a code, it's the &lt;strong&gt;temporary password&lt;/strong&gt;, and the username parameter must appear too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Part 28 of &lt;a href="https://dev.to/hokagedemehin/im-building-a-shopping-app-you-talk-to-heres-the-stack-1l8o"&gt;Building CannyCart&lt;/a&gt;, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line that matters in the definition
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resourceGroupName keeps it in the auth stack — a trigger living in the&lt;/span&gt;
&lt;span class="c1"&gt;// shared function stack would be an auth↔function circular dependency.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineFunction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;custom-message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./handler.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resourceGroupName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, functions deploy into a shared function stack. But a Cognito trigger is &lt;em&gt;referenced by the user pool&lt;/em&gt; (auth → function) while also needing the pool's context — and in a backend where the function stack already depends on auth for other reasons, that reference points the dependency arrow both ways. &lt;code&gt;resourceGroupName: "auth"&lt;/code&gt; places the Lambda &lt;strong&gt;inside the auth stack&lt;/strong&gt;, so the pool and its trigger are siblings and no cross-stack arrow exists. Same lesson as &lt;a href="https://dev.to/hokagedemehin/your-prod-data-faked-one-way-replication-with-deterministic-pii-masking-58cl"&gt;Part 25's&lt;/a&gt; stream wiring and &lt;a href="https://dev.to/hokagedemehin/point-your-camera-at-a-receipt-and-claude-reads-it-jbh"&gt;Part 14's&lt;/a&gt; IAM policy: in a multi-stack backend, &lt;em&gt;where&lt;/em&gt; a resource is declared is a dependency-direction decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways it silently sends nothing
&lt;/h2&gt;

&lt;p&gt;The handler's own docstring is the checklist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Branded Cognito emails — verification + password reset. Cognito injects the
 * code as `event.request.codeParameter`; the body MUST contain it or Cognito
 * rejects the message and nothing is sent.
 *
 * HTML email rules apply: tables + inline styles only (Outlook), no webfonts —
 * Nunito can't ride along, so the system stack stands in and the brand is
 * carried by the colours.
 *
 * AdminCreateUser IS handled … Careful: on that trigger `codeParameter` is
 * the TEMPORARY PASSWORD, not a code, and `usernameParameter` is the email to
 * sign in with. Both must appear in the body or Cognito refuses to send.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1. The code placeholder must be in the body.&lt;/strong&gt; Cognito hands you &lt;code&gt;event.request.codeParameter&lt;/code&gt; — a placeholder token it substitutes &lt;em&gt;after&lt;/em&gt; your handler returns. Build a beautiful template that forgets to include it, and Cognito doesn't send an ugly email; it sends &lt;strong&gt;no email&lt;/strong&gt;, with no error surfaced to the user. Test every trigger type by actually receiving the message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Email HTML is not web HTML.&lt;/strong&gt; Tables for layout, every style inline, no external stylesheets — Outlook still lives in the past. And &lt;strong&gt;no webfonts&lt;/strong&gt;: the app's brand font can't come along, so the template uses the system font stack and lets the &lt;em&gt;colours&lt;/em&gt; — the teal header, the mint code panel — carry the brand identity. A responsive &lt;code&gt;@media&lt;/code&gt; block in a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tag is the one modern concession most clients honour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The invite trigger lies about its parameter names.&lt;/strong&gt; The same handler serves &lt;code&gt;AdminCreateUser&lt;/code&gt; (the admin console's "Invite an admin"). On that trigger, &lt;code&gt;codeParameter&lt;/code&gt; is not a code — it's the &lt;strong&gt;temporary password&lt;/strong&gt; — and &lt;code&gt;usernameParameter&lt;/code&gt; is the email to sign in with. &lt;em&gt;Both&lt;/em&gt; must appear in the body, or Cognito refuses to send. The template branches on &lt;code&gt;triggerSource&lt;/code&gt; and uses different copy and different placeholders for the invite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "branded" means here
&lt;/h2&gt;

&lt;p&gt;One template shell: a teal header with the wordmark, a mint panel with the six-digit code in large spaced digits (30px, 6px letter-spacing on mobile — a code you can read from across the room), a short line of what to do, and a muted footer. The verification and reset emails share the shell and differ in copy; the invite reuses it with the temporary-password panel. Every colour is the design system's email-safe subset — six hex values copied from the shared tokens file, never a gradient (Outlook again).&lt;/p&gt;

&lt;p&gt;The template later grew a second dimension — every one of these emails now arrives in the user's own language, flipped to right-to-left for Arabic — but that's a later part's story, because it came with a war story about Cognito's frozen attribute schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resourceGroupName: "auth"&lt;/code&gt;&lt;/strong&gt; — the trigger belongs beside the pool, not in the function stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include the placeholder or nothing sends.&lt;/strong&gt; Cognito fails closed and quiet; verify by receiving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write email HTML for Outlook&lt;/strong&gt; — tables, inline styles, system fonts, colours as the brand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invite trigger: &lt;code&gt;codeParameter&lt;/code&gt; is the temporary password&lt;/strong&gt;, and the username must appear too.&lt;/li&gt;
&lt;li&gt;Test all three trigger types (sign-up, forgot-password, admin invite) with a real inbox before calling it done.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Part 29 goes back to receipts and fixes the thing that made scanning feel like homework: &lt;strong&gt;scan now, link later&lt;/strong&gt; — the receipt is saved &lt;em&gt;before&lt;/em&gt; the app asks which list it belongs to.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the worst default email your platform sends on your behalf — and have you checked it recently?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cognito</category>
      <category>serverless</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
