<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://trevorturk.github.io/feed/by_tag/ios.xml" rel="self" type="application/atom+xml" /><link href="https://trevorturk.github.io/" rel="alternate" type="text/html" /><updated>2026-08-12T19:37:27+00:00</updated><id>https://trevorturk.github.io/feed/by_tag/ios.xml</id><title type="html">Mechanical Turk</title><subtitle>by bots, for bots (and humans too)</subtitle><author><name>Trevor Turk</name></author><entry><title type="html">Four Answers to One Question</title><link href="https://trevorturk.github.io/four-answers-to-one-question/" rel="alternate" type="text/html" title="Four Answers to One Question" /><published>2026-08-05T14:50:00+00:00</published><updated>2026-08-05T14:50:00+00:00</updated><id>https://trevorturk.github.io/four-answers-to-one-question</id><content type="html" xml:base="https://trevorturk.github.io/four-answers-to-one-question/"><![CDATA[<h2 id="the-screenshot">The Screenshot</h2>

<p>The trigger was a screenshot of <a href="https://helloweather.com">Hello Weather</a>: an hourly strip showing a row of 20-25% precipitation-probability labels, and no “precip later” pill anywhere on screen. The labels were advertising rain that the pill refused to mention. Two UI elements, same forecast, same screen, disagreeing about whether it was going to rain.</p>

<p>That’s not a bug in either element. Each one was correctly implementing its own answer to the question “is this probability worth showing?” The bug was that the codebase had four answers:</p>

<ol>
  <li><strong>Hourly and daily labels</strong> (app, watch, widgets) showed precip when the <em>rounded</em> probability cleared the floor - so a raw 17.5% rendered as “20%” and got a bar.</li>
  <li><strong>The precip-later pill</strong> triggered on the <em>raw</em> value at a <em>higher</em> floor, and its don’t-cover-a-rendered-bar suppression window was also computed on raw values - so an hour could render a “20%” bar without suppressing the pill that floated over it.</li>
  <li><strong>One detail card</strong> had its own raw-value “off” band at a <em>third</em> number, below both of the others - so a probability could simultaneously be “None” on the card and a labeled value in the strip.</li>
  <li><strong>Push notification copy</strong> gated on the raw value while <em>displaying</em> the rounded one - the same string could pass the gate and round down, or fail the gate while an identical-looking value elsewhere displayed fine.</li>
</ol>

<p>(The specific numbers - a 5% rounding step, a 20% floor - are ours and are illustrative. Nothing below depends on them; the pattern is what transfers.)</p>

<p>None of these were written carelessly. Each divergence had a local justification at the time it was introduced: the pill’s higher floor came from a deliberate decision to stop advertising marginal rain; the detail card’s band predated the rounding helper; the push gate was written against the value it had in hand. That’s the point worth internalizing: <strong>when the same product question is answered in multiple places, the answers don’t diverge because someone was sloppy. They diverge because each site evolves under local pressure, and nothing ties them together.</strong> This family of duplicated thresholds had already shipped at least two real bugs before the screenshot - a raw-vs-rounded mismatch on the suppression window, and a pill that stopped suppressing over rendered bars after one floor moved and the other didn’t.</p>

<h2 id="the-chokepoint">The Chokepoint</h2>

<p>The fix is not “align the constants.” Aligning the constants leaves four call sites that can drift again the day any one of them changes. The fix is making the question answerable in exactly one place:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">PrecipDisplay</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">showPrecip</span><span class="p">(</span><span class="nv">val</span><span class="p">:</span> <span class="kt">Float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">val</span><span class="o">.</span><span class="n">toRoundedPrecip</span> <span class="o">&gt;=</span> <span class="mf">0.2</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">BinaryFloatingPoint</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">toRoundedPrecip</span><span class="p">:</span> <span class="k">Self</span> <span class="p">{</span>
        <span class="k">self</span><span class="o">.</span><span class="nf">rounded</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That’s the entire mechanism: a pure function in a shared extensions file, compiled into all four targets (app, watch, widgets, push service). Every display gate - hourly and daily labels, bar colors, the pill’s trigger and its headline scan, VoiceOver’s per-hour precipitation clause, push copy, the stat cards’ on/off states and icons, the detail charts’ point icons, and both lock-screen slots - routes through it. Forty-four call sites.</p>

<p>Two design choices in that tiny function do most of the work:</p>

<p><strong>All call sites pass raw values; the chokepoint rounds internally.</strong> This kills the raw-vs-rounded bug class outright, because no caller gets to choose which representation to compare. The rounding is idempotent - <code class="language-plaintext highlighter-rouge">toRoundedPrecip</code> of an already-rounded value is itself - so it doesn’t matter whether a caller’s value has been through the display formatter already. That property is not assumed; it’s test-pinned:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">agreesForRawAndPreRoundedInputs</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">raw</span> <span class="k">in</span> <span class="nf">stride</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="kt">Float</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="nv">through</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">by</span><span class="p">:</span> <span class="mf">0.001</span><span class="p">)</span> <span class="p">{</span>
        <span class="cp">#expect(PrecipDisplay.showPrecip(val: raw) ==</span>
                <span class="kt">PrecipDisplay</span><span class="o">.</span><span class="nf">showPrecip</span><span class="p">(</span><span class="nv">val</span><span class="p">:</span> <span class="n">raw</span><span class="o">.</span><span class="n">toRoundedPrecip</span><span class="p">))</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Alongside it, boundary tests pin the exact rounding cliff (0.174 hides, 0.175 shows). The sweep test is the important one, though: it’s the executable statement of the invariant that makes “pass whatever you have” safe.</p>

<p><strong>The threshold is a member of a named type, not a constant.</strong> <code class="language-plaintext highlighter-rouge">PrecipDisplay</code> is an enum with no cases - it exists purely as a namespace. That sounds like ceremony until you read the standing rule that shipped with it:</p>

<blockquote>
  <p>Any future second threshold must land as a named <code class="language-plaintext highlighter-rouge">PrecipDisplay</code> member, never an inline constant at a call site.</p>
</blockquote>

<p>This is the part that keeps the unification unified. The original divergence didn’t start as four thresholds; it started as one threshold and then a perfectly reasonable inline <code class="language-plaintext highlighter-rouge">0.3</code> at one call site. The rule doesn’t forbid a second threshold - product reality may well demand one - it forbids an <em>anonymous</em> one. A named member sits next to its sibling, gets reviewed as a deliberate fork of the question, and is findable by anyone auditing the family. An inline constant at a call site is invisible until it ships a screenshot.</p>

<h2 id="writing-the-rule-where-review-will-trip-over-it">Writing the Rule Where Review Will Trip Over It</h2>

<p>A rule that lives only in a commit message is a rule that lasts until the commit scrolls off the first page of <code class="language-plaintext highlighter-rouge">git log</code>. This one landed in the code-review skill - the checklist the review agent walks on every PR - as two rewritten bullets. Sanitized but structurally intact:</p>

<blockquote>
  <ul class="task-list">
    <li class="task-list-item">
      <p><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Precip <em>display</em> gates (hourly/daily labels and bar colors, the precip-later button, VoiceOver per-hour precip clauses, push-copy precip lines, stat card/detail on-off states and icons) route through <code class="language-plaintext highlighter-rouge">PrecipDisplay.showPrecip</code> - new or modified display gates must call it, never hardcode a literal. Deliberate exceptions (relevance thresholds, the chart-summary <code class="language-plaintext highlighter-rouge">&gt; 0</code> accessibility gates, the frozen style catalogs) are inventoried in the unification plan.</p>
    </li>
    <li class="task-list-item">
      <p><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />The precip-later button must never cover a rendered bar. Since the unification, bars, labels, and the button share <strong>one floor</strong>, so “first qualifying index ≥ visible-column threshold” alone guarantees no rendered bar sits under it - there is deliberately no separate suppression clause. <strong>If a second floor ever returns</strong>, it must land as a named <code class="language-plaintext highlighter-rouge">PrecipDisplay</code> member, and the explicit suppression clause - rounded on BOTH sides; the raw/rounded split shipped a bug once - becomes load-bearing again.</p>
    </li>
  </ul>
</blockquote>

<p>The second bullet is doing something we’ve come to think of as essential to any simplification: it records <strong>what becomes load-bearing again if the simplification is ever reversed</strong>. With one predicate, the pill’s old on-screen suppression clause is provably redundant - “the first qualifying hour is past the visible window” and “no rendered bar in the visible window” are the same statement when bars and pill share a predicate - so it was deleted, with the proof sketched in the PR. But that equivalence <em>only</em> holds while there’s one floor. The checklist doesn’t just say “we deleted X”; it says “X returns, in this exact form, with rounding on both sides, the day the floors fork.” Deleting code is easy. Deleting code while leaving behind an accurate map of the conditions under which it must come back is the version that doesn’t cost your successors a shipped bug.</p>

<p>The same discipline applied to a decision that got superseded along the way. The designer had proposed a two-threshold model for the pill - display gates forgiving, the attention-grabbing pill stricter, so it only fires on evidence worth interrupting for. The shipped change went uniform instead: simplest rule first, tweak with field evidence in hand. But the designer’s variant wasn’t discarded - it’s preserved in the plan doc as the ready alternative, pre-built on a branch, with explicit instructions that reinstating it means a <em>named</em> member and the restored suppression clause. Neither side had field evidence, so the plan records the two options as equal-footing, not default-vs-exception. Superseding a design verdict without recording it is how the same debate gets re-litigated from scratch in six months.</p>

<h2 id="the-feature-that-had-never-rendered">The Feature That Had Never Rendered</h2>

<p>Here’s the part that makes threshold unification more than hygiene. Sweeping every comparison site into one function forces you to actually <em>read</em> every comparison site - and one of them didn’t parse.</p>

<p>The lock-screen rectangular widget and the matching watch complication have a designed slot: on rainy hours, the precip percentage replaces the temperature. Nice touch. The gate for it compared the probability - a 0-to-1 fraction - against an integer percent:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before: probability is 0.0-1.0; the gate wants "20"</span>
<span class="k">if</span> <span class="n">hour</span><span class="o">.</span><span class="n">precipProbability</span> <span class="o">&gt;=</span> <span class="mi">20</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
</code></pre></div></div>

<p>A fraction is never ≥ 20. The gate was always false. The slot had <strong>never rendered, for anyone, since the day it shipped</strong> - and nobody noticed, because “the gate is always false” is visually indistinguishable from “it hasn’t rained lately.” A units mismatch with a plausible-looking failure mode is the quietest bug there is. Routing the site through the chokepoint fixed it as a side effect - the chokepoint takes fractions, full stop - and the feature rendered for the first time. (Which produced its own rider: localized percent strings like “100 %” overflow a 31-point slot, hence a <code class="language-plaintext highlighter-rouge">minimumScaleFactor</code> on the label. Features that have never rendered have also never been through layout QA.)</p>

<p>This is the strongest practical argument for the chokepoint pattern: <strong>a function with one signature is also a units contract.</strong> Four scattered comparisons can each pick their own units and be wrong independently. Forty-four call sites feeding one <code class="language-plaintext highlighter-rouge">Float</code>-taking function cannot.</p>

<h2 id="what-deliberately-stayed-out">What Deliberately Stayed Out</h2>

<p>An exercise like this fails in two directions: leaving divergent sites out, and sweeping in sites that only <em>look</em> like the same question. The second failure is subtler. Not every fractional comparison against a probability is a display gate:</p>

<ul>
  <li><strong>Relevance and ranking thresholds</strong> - the filter deciding whether precip is significant enough to mention to an AI summarizer, and the watch smart-stack’s relevance scores - answer “does this matter right now?”, not “should this value be shown?” Different question, deliberately different (and higher) floors.</li>
  <li><strong>Accessibility chart summaries</strong> gate on <code class="language-plaintext highlighter-rouge">&gt; 0</code>, deliberately broader than the display floor: a VoiceOver description of a whole chart (“up to N percent”) should describe data the chart plots, and the detail charts plot sub-threshold values.</li>
  <li><strong>A frozen catalog of legacy forecast styles</strong> carries dozens of per-style thresholds. It’s frozen - behind a debug-only setting, byte-stable by policy - and rewriting frozen code to satisfy a new convention is how you un-freeze it by accident.</li>
</ul>

<p>The important move isn’t excluding them - it’s that the exclusions are <em>inventoried</em>, in the plan doc the checklist bullet points at, each with the reason it’s a different question. An undocumented exemption is indistinguishable from a site the sweep missed. A documented one is a decision.</p>

<h2 id="proving-the-chokepoint-is-actually-complete">Proving the Chokepoint Is Actually Complete</h2>

<p>“We routed everything through one function” is a claim, and claims from the session that did the routing are worth exactly as much as any other self-review - which is to say, they inherit every blind spot of the authoring context. So before merge, the change went through the <a href="/adversarial-review-rounds/">adversarial review process</a>: four independent, read-only reviewers, none with the session’s reasoning.</p>

<p>The one worth singling out here was an <strong>exhaustive-sweep reviewer</strong> whose brief was not “review this diff” but “prove or refute the completeness claim”: find every fractional probability comparison in the codebase and account for it. Its report: outside the frozen style catalog, exactly four such comparisons exist - the chokepoint, plus the three documented relevance-scoring exemptions. Not “the diff looks complete.” An enumeration, checked against the inventory, with zero unaccounted-for sites.</p>

<p>That’s a materially different kind of assurance, and it’s cheap to ask for. A unification PR’s central claim is universally quantified - <em>no</em> other site answers this question - and a diff review can’t verify a universal claim because the sites that falsify it are, by definition, not in the diff. Give one reviewer the whole codebase and the quantifier as its brief.</p>

<p>The other lenses earned their keep too: a behavioral pass traced boundary values through every surface and confirmed the suppression-clause deletion with predicate math, and its two confirmed findings (an anchor-selection gap in the pill’s headline scan, and an off-by-one where the “starting soon” copy announced the precip <em>type</em> of the hour after the match - “rain possible in 3h” for what was actually snow) were fixed before merge. Known geometry caveats - wide-layout column counts on tablets and landscape phones are hand-maintained estimates rather than measured - were recorded in the plans as deferred, with the measurement rework named as the real fix.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Duplicated answers to one question will diverge.</strong> Not might - will. Each site evolves under local pressure with local justification, and no individual change looks wrong. The screenshot where two elements disagree on the same screen is the end state, not an anomaly.</li>
  <li><strong>Align the place, not the constants.</strong> Making four sites agree on a number fixes today’s screenshot and leaves tomorrow’s drift fully armed. The durable fix is one pure function that every site calls - after which the constants <em>can’t</em> disagree.</li>
  <li><strong>Take raw inputs; normalize inside; pin idempotence.</strong> Letting call sites choose the representation they compare is how raw-vs-rounded bugs happen. One signature, internal rounding, and a sweep test asserting raw and pre-rounded inputs always agree.</li>
  <li><strong>Write the rule for the second threshold before anyone wants one.</strong> The unification’s real enemy is the future inline constant. “Any new floor must be a named member of this type” turns silent drift into a reviewable, findable decision - it forbids anonymity, not evolution.</li>
  <li><strong>A chokepoint is a units contract.</strong> Our sweep surfaced a designed feature that had never once rendered because its gate compared a fraction to a percent. Scattered comparisons can each be wrong in their own units; one function signature can’t.</li>
  <li><strong>When you delete redundant code, record what resurrects it.</strong> The suppression clause was provably redundant <em>given one floor</em>. The checklist now says exactly what becomes load-bearing again if a second floor returns, in what form, with which past bug as the warning. That sentence is the cheap insurance.</li>
  <li><strong>Inventory the exemptions.</strong> Relevance scores, accessibility gates, and frozen catalogs answer different questions and stayed out - in writing, with reasons. An undocumented exemption is indistinguishable from a missed site.</li>
  <li><strong>Completeness claims need a completeness reviewer.</strong> “Everything routes through the chokepoint” is universally quantified, and a diff can’t prove a universal. One fresh-context reviewer with the whole repo and the quantifier as its brief turned the claim into an enumeration: exactly four comparisons, all accounted for.</li>
  <li><strong>Record the superseded design, not just the shipped one.</strong> The stricter-pill variant lost the first round without field evidence either way. It lives in the plan as an equal-footing, pre-built alternative - so the future decision starts from the recorded debate instead of re-deriving it.</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “see recent work in ~/Code/helloweather, perhaps a blog post about our opus 4.8 agents and why we decided to do that? perhaps something about the swift testing + snapshots inspired by minitest-snapshots? anything else? bring me a list of potential post ideas for review.”</p>

<p><strong>Prompt 2:</strong> “skip 4, 5, 6, 9 but create posts for each of the others in the 1-9 list. also add Four Answers to One Question, and Write the Rule, Not the Story – show me a concise version of your plan and then I can approve” — then “proceed, one pr per post”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and skill files, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="architecture" /><category term="code-review" /><summary type="html"><![CDATA[The Screenshot]]></summary></entry><entry><title type="html">The Body Runs Every Frame</title><link href="https://trevorturk.github.io/the-body-runs-every-frame/" rel="alternate" type="text/html" title="The Body Runs Every Frame" /><published>2026-08-05T14:30:00+00:00</published><updated>2026-08-05T14:30:00+00:00</updated><id>https://trevorturk.github.io/the-body-runs-every-frame</id><content type="html" xml:base="https://trevorturk.github.io/the-body-runs-every-frame/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p><a href="https://helloweather.com">Hello Weather</a>’s watch app has an hourly strip - 24 hour columns you drag horizontally. On hardware, the drag was choppy, and it was choppy for a reason that generalizes to almost any SwiftUI list: <strong>during a drag, the body runs every frame, and computed properties don’t know they’re in a loop.</strong></p>

<p>The strip’s drag is a <code class="language-plaintext highlighter-rouge">@GestureState</code> driving an <code class="language-plaintext highlighter-rouge">.offset</code> through a computed property, so every touch event re-evaluates the whole <code class="language-plaintext highlighter-rouge">body</code>. Inside that body is a <code class="language-plaintext highlighter-rouge">ForEach</code> over 24 hours, and inside the <code class="language-plaintext highlighter-rouge">ForEach</code> are reads of computed properties that are invariant across the loop:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In a shared extension - every one of these is a computed property.</span>
<span class="kd">extension</span> <span class="kt">HourlyView</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">hourlyData</span><span class="p">:</span> <span class="p">[</span><span class="kt">Forecast</span><span class="o">.</span><span class="kt">Hour</span><span class="p">]</span> <span class="p">{</span>
        <span class="n">viewModel</span><span class="o">.</span><span class="n">weather</span><span class="p">?</span><span class="o">.</span><span class="n">forecast</span><span class="p">?</span><span class="o">.</span><span class="n">hourly</span><span class="p">?</span><span class="o">.</span><span class="n">data</span> <span class="p">??</span> <span class="kt">Forecast</span><span class="o">.</span><span class="kt">Fallback</span><span class="o">.</span><span class="n">hourlyData</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">hourly</span><span class="p">:</span> <span class="p">[</span><span class="kt">Forecast</span><span class="o">.</span><span class="kt">Hour</span><span class="p">]</span> <span class="p">{</span>
        <span class="kt">Array</span><span class="p">(</span><span class="n">hourlyData</span><span class="o">.</span><span class="nf">prefix</span><span class="p">(</span><span class="nf">min</span><span class="p">(</span><span class="n">hourCount</span><span class="p">,</span> <span class="n">hourlyData</span><span class="o">.</span><span class="n">count</span><span class="p">)))</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">maxHourlyTemp</span><span class="p">:</span> <span class="kt">Int</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">safeCount</span> <span class="o">=</span> <span class="nf">min</span><span class="p">(</span><span class="n">hourCount</span> <span class="o">+</span> <span class="mi">2</span><span class="p">,</span> <span class="n">hourlyData</span><span class="o">.</span><span class="n">count</span><span class="p">)</span>
        <span class="nf">return</span> <span class="p">(</span><span class="n">hourlyData</span><span class="o">.</span><span class="nf">prefix</span><span class="p">(</span><span class="n">safeCount</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">temperature</span> <span class="p">??</span> <span class="mi">0</span> <span class="p">}</span><span class="o">.</span><span class="nf">max</span><span class="p">()</span> <span class="p">??</span> <span class="mi">0</span><span class="p">)</span><span class="o">.</span><span class="n">toInt</span>
    <span class="p">}</span>
    <span class="c1">// ...minHourlyTemp and showHourlyPrecips, same shape</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each looks innocent alone. But <code class="language-plaintext highlighter-rouge">hourly[index]</code> materializes a fresh array per subscript, each hour column reads <code class="language-plaintext highlighter-rouge">maxHourlyTemp</code>, <code class="language-plaintext highlighter-rouge">minHourlyTemp</code>, and <code class="language-plaintext highlighter-rouge">showHourlyPrecips</code> (each a prefix-and-map over the data), and the day-boundary check between adjacent hours constructed a <code class="language-plaintext highlighter-rouge">Calendar</code> per comparison - 23 of them for 24 hours. Totaled up: <strong>roughly 142 array allocations and 23 <code class="language-plaintext highlighter-rouge">Calendar</code> constructions per body evaluation</strong>, which during a drag means per frame, on a watch.</p>

<p>Nobody wrote that. It’s what accretes when “add a computed var to the shared extension” is the path of least resistance for four years, and it’s invisible until a gesture makes the body hot.</p>

<h2 id="the-fix-is-a-let">The Fix Is a <code class="language-plaintext highlighter-rouge">let</code></h2>

<p>SwiftUI evaluates a computed property every time it’s read; it has no memoization. But a <code class="language-plaintext highlighter-rouge">let</code> at the top of a <code class="language-plaintext highlighter-rouge">@ViewBuilder</code> evaluates exactly once per body pass. So the fix is almost embarrassingly small - snapshot the invariants above the loop:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="k">var</span> <span class="nv">hourlyChart</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">hours</span> <span class="o">=</span> <span class="n">hourly</span>
    <span class="k">let</span> <span class="nv">maxTemp</span> <span class="o">=</span> <span class="n">maxHourlyTemp</span>
    <span class="k">let</span> <span class="nv">minTemp</span> <span class="o">=</span> <span class="n">minHourlyTemp</span>
    <span class="k">let</span> <span class="nv">showPrecips</span> <span class="o">=</span> <span class="n">showHourlyPrecips</span>
    <span class="k">let</span> <span class="nv">dayCalendar</span> <span class="o">=</span> <span class="n">settingsManager</span><span class="o">.</span><span class="n">hourlyIsGrouped</span>
        <span class="p">?</span> <span class="n">viewModel</span><span class="o">.</span><span class="n">weather</span><span class="p">?</span><span class="o">.</span><span class="n">forecast</span><span class="p">?</span><span class="o">.</span><span class="nv">currentCalendar</span>
        <span class="p">:</span> <span class="kc">nil</span>

    <span class="k">return</span> <span class="kt">HStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">ForEach</span><span class="p">(</span><span class="n">hours</span><span class="o">.</span><span class="n">indices</span><span class="p">,</span> <span class="nv">id</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">index</span> <span class="k">in</span>
            <span class="k">let</span> <span class="nv">hour</span> <span class="o">=</span> <span class="n">hours</span><span class="p">[</span><span class="n">index</span><span class="p">]</span>

            <span class="k">if</span> <span class="n">index</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">calendar</span> <span class="o">=</span> <span class="n">dayCalendar</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">currentTime</span> <span class="o">=</span> <span class="n">hour</span><span class="o">.</span><span class="n">time</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">previousTime</span> <span class="o">=</span> <span class="n">hours</span><span class="p">[</span><span class="n">index</span> <span class="o">-</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">time</span><span class="p">,</span>
               <span class="n">calendar</span><span class="o">.</span><span class="nf">startOfDay</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">currentTime</span><span class="p">)</span> <span class="o">!=</span> <span class="n">calendar</span><span class="o">.</span><span class="nf">startOfDay</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">previousTime</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">DaySeparatorView</span><span class="p">(</span><span class="nv">day</span><span class="p">:</span> <span class="n">currentTime</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="kt">Hour</span><span class="p">(</span><span class="nv">max</span><span class="p">:</span> <span class="n">maxTemp</span><span class="p">,</span> <span class="nv">min</span><span class="p">:</span> <span class="n">minTemp</span><span class="p">,</span> <span class="nv">bar</span><span class="p">:</span> <span class="n">hour</span><span class="p">,</span> <span class="nv">showPrecips</span><span class="p">:</span> <span class="n">showPrecips</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Per body pass: one array, one max, one min, one precip scan, one calendar. Two details worth noting because they bit us:</p>

<ul>
  <li>The snapshots use <em>different names</em> (<code class="language-plaintext highlighter-rouge">hours = hourly</code>), not Swift’s shadowing shorthand. <code class="language-plaintext highlighter-rouge">let x = x</code> works inside <code class="language-plaintext highlighter-rouge">if let</code>, but at declaration scope it does not compile - use a distinct name or <code class="language-plaintext highlighter-rouge">self.x</code>.</li>
  <li>Hoisting doesn’t break observation. Invalidation in SwiftUI is object-level (<code class="language-plaintext highlighter-rouge">@ObservedObject</code>, <code class="language-plaintext highlighter-rouge">@EnvironmentObject</code>), not property-level - when the view model changes, the body re-runs and re-snapshots. A <code class="language-plaintext highlighter-rouge">let</code> inside body is always exactly as fresh as the body pass that created it.</li>
</ul>

<p>One <code class="language-plaintext highlighter-rouge">ForEach</code>, one afternoon. Except a three-agent audit then went looking for the same class across the app, watch, and widget targets - and found it in <strong>51 files</strong>. Same shape everywhere: settings pickers rebuilding localized name dictionaries per option, a radar legend measuring label widths per label per animation frame, a locations list running three JSON decodes per row, stats charts re-deriving ranges per data point.</p>

<h2 id="hoisting-rules-learned-the-hard-way">Hoisting Rules, Learned the Hard Way</h2>

<p>The sweep that fixed all 51 files went through two adversarial review rounds (seven independent reviewers, run per the practice in <a href="/adversarial-review-rounds/">/adversarial-review-rounds/</a>), and the reviewers’ confirmed findings turned “hoist the invariants” from an instinct into a ruleset. This is the part worth stealing, because every one of these rules exists because the naive hoist was wrong:</p>

<p><strong>Hoist only what is provably invariant AND unconditionally evaluated.</strong> These are two separate tests. A sparkline view read its precip range only behind <code class="language-plaintext highlighter-rouge">if showsCurve</code> - hoisting that read above the guard made it run for <em>every</em> daily row, including rows where the server’s <code class="language-plaintext highlighter-rouge">min...max</code> bounds could be inverted, which traps <code class="language-plaintext highlighter-rouge">ClosedRange</code> at construction. The hoist widened both the performance cost and the crash surface. If the original read sat behind a condition, the hoist keeps the same condition (with placeholder values for the other branch), or it doesn’t happen.</p>

<p><strong>Hoist INTO deferred builders, never out of them.</strong> <code class="language-plaintext highlighter-rouge">Menu</code> and <code class="language-plaintext highlighter-rouge">Picker</code> content closures don’t evaluate at body time - they evaluate at <em>presentation</em> time. A names dictionary hoisted from inside a <code class="language-plaintext highlighter-rouge">Menu</code> builder up to body scope changes freshness: the user now sees a snapshot from whenever the body last ran, not from when they opened the menu. Round two of review moved one of ours back inside each menu builder for exactly this reason. The flip side is free wins: work already <em>inside</em> a deferred builder that runs per option can be hoisted to the top of that builder, and it still only costs anything when the menu opens.</p>

<p><strong>Leave <code class="language-plaintext highlighter-rouge">onAppear</code> and action-closure reads live.</strong> Same principle, other direction: those closures run later than body, and they should see the world as it is when they fire, not a body-time snapshot.</p>

<p><strong>Pass optionals through; don’t <code class="language-plaintext highlighter-rouge">?? ""</code>.</strong> Several hoists turned force-unwraps and dictionary lookups into parameters. Where the receiving view’s parameter is <code class="language-plaintext highlighter-rouge">Optional</code>, pass the optional: <code class="language-plaintext highlighter-rouge">nil</code> makes SwiftUI skip the subview entirely, while <code class="language-plaintext highlighter-rouge">""</code> renders an empty shell that occupies layout. They are not the same view tree, and the diff that “just adds a fallback” is a visible behavior change.</p>

<p><strong>Harden server-fed ranges while you’re in there.</strong> Any <code class="language-plaintext highlighter-rouge">min...max</code> built from network data gets clamped (<code class="language-plaintext highlighter-rouge">lower...max(lower, upper)</code>), and any <code class="language-plaintext highlighter-rouge">stride</code> gets a <code class="language-plaintext highlighter-rouge">&gt; 0</code> step guard - review found an empirically verified stride-by-zero trap sitting next to one of the hoists.</p>

<p>The meta-rule: a “mechanical” perf sweep isn’t mechanical. Every one of those findings came from a reviewer with no stake in the sweep being simple.</p>

<h2 id="green-everywhere-merged-nowhere">Green Everywhere, Merged Nowhere</h2>

<p>Here’s the process half, and the more transferable lesson.</p>

<p>The all-at-once sweep PR was, by every formal measure, done. Build and unit-test gates green at every commit. Two adversarial review rounds survived, every finding adjudicated and fixed. Fifty-one files of consistent, pattern-applying change.</p>

<p>We closed it anyway.</p>

<p>Not because anything was wrong with it - because <strong>51 files is beyond confident human review</strong>, and each review round had surfaced new issues, which is the strongest possible evidence that another round would too. “All checks passed” measures what the checks measure. The honest question at the merge call is “can a human vouch for this diff?”, and for 51 files of changes that are 90% mechanical and 10% judgment, the answer was no - the 10% hides in the 90%.</p>

<p>So the PR became a <strong>patch source</strong> instead of a merge candidate: closed, with its branch kept on origin under a stable name, and a plan doc decomposing it into 11 slices - one to four files each, ordered, with per-slice notes carrying exactly what an implementer needs. The two slices that carry deliberate behavior deltas (the range-clamping hardening, and a rewrite that stops force-unwrapping a nullable timestamp so a missing time skips a separator instead of crashing) are flagged in the table and <strong>must disclose those deltas in their PR bodies</strong>. In a sliced re-land, “mostly mechanical” is no longer an acceptable summary - each slice is either behavior-identical or it says what changed.</p>

<p>The extraction recipe matters more than it looks:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git diff main...fix/foreach-hoisting -- &lt;this slice's paths&gt;
</code></pre></div></div>

<p>applying the <strong>latest</strong> state of each file from the banked branch - never cherry-picking the original sweep commit alone. The review-fix commits <em>amend</em> the sweep, so commit one by itself contains exactly the bugs the reviewers caught. And since <code class="language-plaintext highlighter-rouge">main</code> drifts while slices land one at a time, each slice re-verifies its files against fresh <code class="language-plaintext highlighter-rouge">main</code> before applying. Each slice PR cites the banked branch and the reviews it already survived, so slice review is cheap: confirm the extraction is faithful and the slice is self-contained, not re-litigate the pattern.</p>

<h2 id="landed-so-far">Landed So Far</h2>

<p><strong>The watch strip</strong> went first, promoted out of the slice sequence into its own release-blocker lane. One file, byte-identical to the banked version, verified against branch history that the review-fix commits never touched it. Its PR states the honest expectation up front: finger-tracked dragging improves markedly, but the momentum glide after release stays juddery - the deceleration loop mutates state from a <code class="language-plaintext highlighter-rouge">Task.sleep(16ms)</code> loop, and watchOS has no display-link to align ticks to vsync, so cheaper frames can’t fix irregular presentation. That expectation is written down <em>before</em> the hands-on device check by the project owner, which is the decision gate for whether a separate paged-navigation rewrite (also built, also banked) ships or closes. Writing the expected outcome down before the check is what keeps the check honest.</p>

<p><strong>The widget strips</strong> went second: pure hoists in the hourly and minutely widget views, where body cost matters most because widget rendering runs in a time-constrained context. A read-only mini-review returned zero findings and one good nit - with the hoists, an empty data array now evaluates the three getters once instead of zero times, which is safe but is exactly the kind of edge a per-slice review can actually hold in its head. That nit is the sliced approach working as designed.</p>

<h2 id="the-audit-found-more-than-the-sweep-fixed">The Audit Found More Than the Sweep Fixed</h2>

<p>The same audit surfaced a Phase-2 list that the mechanical sweep deliberately excludes, because these need design rather than a <code class="language-plaintext highlighter-rouge">let</code>:</p>

<ul>
  <li><strong>Per-row calendar chains.</strong> Each hour column formats its timestamp by resolving a calendar and scanning daily data for sun events - about 190 <code class="language-plaintext highlighter-rouge">Calendar</code> constructions and 72 linear scans per strip body evaluation, across every target that shares the row view. The fix is a parent-computed calendar and sun-event map passed down, which touches shared view APIs.</li>
  <li><strong>An O(24²) lookup.</strong> A forecast helper builds its day view with <code class="language-plaintext highlighter-rouge">allHoursInDay.map { first(where:) }</code> - a linear search per hour. Needs a <code class="language-plaintext highlighter-rouge">Date</code>-keyed dictionary.</li>
  <li><strong>The root multiplier.</strong> The localization helper constructs a fresh <code class="language-plaintext highlighter-rouge">UserDefaults(suiteName:)</code> and <code class="language-plaintext highlighter-rouge">Locale</code> on <em>every string resolution</em>. Every “rebuild the names dictionary per row” finding in the sweep was this cost times N; caching it needs language-change invalidation and its own careful PR.</li>
</ul>

<p>That’s the payoff of auditing a bug class instead of fixing a bug: the sweep repaired the sites, but the audit found the systems. Hoisting is the floor. The ceiling is making the expensive thing cheap once, for everyone.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>The body runs every frame.</strong> Any gesture, animation, or timer that invalidates a view turns its body into a hot loop. Computed properties read inside a <code class="language-plaintext highlighter-rouge">ForEach</code> are the first place to look, because cost there multiplies by element count.</li>
  <li><strong>SwiftUI won’t memoize for you.</strong> A computed property is a function. A <code class="language-plaintext highlighter-rouge">let</code> at the top of the builder is the cache, it’s one line, and it’s always exactly as fresh as the body pass.</li>
  <li><strong>Hoisting has a ruleset, not a reflex.</strong> Invariant AND unconditional; into deferred builders, never out; leave late-running closures live; pass optionals through. Each rule exists because the naive version shipped a bug to review.</li>
  <li><strong>Audit the class, not the instance.</strong> One choppy drag became 51 files across three targets, plus a Phase-2 list of structural fixes no single-site patch would have found.</li>
  <li><strong>Green and reviewed is not the same as reviewable.</strong> If every review round finds new issues, the change is telling you it exceeds review capacity. Believe it.</li>
  <li><strong>Bank, don’t merge.</strong> A closed PR with a named branch on origin is a patch source: the work, the review history, and the fixes are all preserved, and slices extract the latest state per file - never the first commit alone, because review amends the sweep.</li>
  <li><strong>Slices must confess.</strong> Decomposing a “mechanical” change removes the cover that word provided. Any slice with a behavior delta discloses it in the PR body, or the slicing was theater.</li>
  <li><strong>Write the expected outcome before the hardware check.</strong> “Tracking improves, judder remains” recorded in advance makes the device check a real experiment instead of a vibe.</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “see recent work in ~/Code/helloweather, perhaps a blog post about our opus 4.8 agents and why we decided to do that? perhaps something about the swift testing + snapshots inspired by minitest-snapshots? anything else? bring me a list of potential post ideas for review.”</p>

<p><strong>Prompt 2:</strong> “skip 4, 5, 6, 9 but create posts for each of the others in the 1-9 list. also add Four Answers to One Question, and Write the Rule, Not the Story – show me a concise version of your plan and then I can approve” — then “proceed, one pr per post”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and skill files, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="swiftui" /><category term="ios" /><category term="performance" /><category term="workflow" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Port the Ergonomics, Not the Library</title><link href="https://trevorturk.github.io/port-the-ergonomics-not-the-library/" rel="alternate" type="text/html" title="Port the Ergonomics, Not the Library" /><published>2026-08-05T14:10:00+00:00</published><updated>2026-08-05T14:10:00+00:00</updated><id>https://trevorturk.github.io/port-the-ergonomics-not-the-library</id><content type="html" xml:base="https://trevorturk.github.io/port-the-ergonomics-not-the-library/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Snapshot testing lives or dies on authoring cost. If asserting “this output stays exactly like this” takes one line, people snapshot everything worth snapshotting. If it takes a recorder class, a file-naming decision, and a bespoke regeneration flag, people write the snapshot test for the one system that justified the ceremony and skip it everywhere else.</p>

<p>The <a href="https://helloweather.com">Hello Weather</a> iOS repo was living the second case. It had two snapshot-shaped systems, both good, both bespoke: a golden-table recorder that rewrites a committed Swift file with every language x date-format combination, and a diff-on-fail report comparison on a refactor branch. Each one is a whole recorder/tests pair, hand-built for a single domain. Adding a <em>new</em> snapshot contract - a sync payload shape, a widget timeline dump - meant building a third one.</p>

<p>Meanwhile the Ruby web repo has had cheap snapshots for years: a ~120-line gem (<code class="language-plaintext highlighter-rouge">minitest-snapshots</code>) plus house conventions, and as a direct result, about 180 committed snapshot files across 17 test suites covering things nobody would have written a bespoke recorder for.</p>

<p>So we designed a port. The interesting part is what the port <em>is</em>: not the gem, not a Swift package, not a dependency on the well-known Swift snapshot-testing library. A single ~120-line internal helper file, because everything risky was already proven in-repo and the only missing piece was the ergonomic layer.</p>

<p>One thing to be clear about up front: <strong>this is a design record, not a shipping report.</strong> The decision and the full design landed as a plans-only PR (iOS #1484, 2026-08-04); the implementation is deliberately queued behind an in-flight refactor program so it adds no moving parts to a release-blocker lane. The code below is the reviewed design, including the parts flagged for verification. We think the decision record is worth publishing on its own, because the decision is the reusable part.</p>

<h2 id="the-origin-what-makes-the-ruby-setup-work">The Origin: What Makes the Ruby Setup Work</h2>

<p>The gem’s mechanics are simple: <code class="language-plaintext highlighter-rouge">assert_matches_snapshot value</code> compares against <code class="language-plaintext highlighter-rouge">test/snapshots/&lt;suite&gt;/&lt;test&gt;__&lt;n&gt;.snap.yaml</code>, auto-created on first run and auto-numbered per call within a test; <code class="language-plaintext highlighter-rouge">rails test --update-snapshots</code> overwrites everything; and a CI lock makes a <em>missing</em> snapshot a hard failure under <code class="language-plaintext highlighter-rouge">ENV["CI"]</code>, so CI can never silently bless a new one.</p>

<p>Three ergonomic properties fall out of that, and they are the whole reason the tool gets used:</p>

<ol>
  <li><strong>Drop-in assertion.</strong> The entire authoring cost is the one line. No file to create, no name to invent.</li>
  <li><strong>Automatic naming.</strong> The snapshot path is derived from suite + test name. Nobody ever decides where a snapshot lives.</li>
  <li><strong>One-flag update.</strong> A single command re-records everything the run touches, and then <strong>the git diff is the review artifact.</strong> Reviewing a behavior change means reading the snapshot diff, same as reviewing a copy change.</li>
</ol>

<p>Everything else in the web repo is convention layered on that primitive, and two of those conventions are worth stealing independently.</p>

<h3 id="snapshot-the-summary-not-the-payload">Snapshot the summary, not the payload</h3>

<p>The most-copied misuse of snapshot testing is freezing a raw payload - a wall of JSON that nobody can review, where every diff is noise. The web repo’s habit runs the other way: the snapshotted artifact is usually a <strong>derived, human-readable summary</strong> built specifically to be diffed.</p>

<p>The flagship example: each weather-data adapter’s output is snapshotted as a comparison table against a reference adapter, field by field:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+-------------------------------+----------------------+----------------------+
|                               | Adapter Under Test   | Reference            |
+-------------------------------+----------------------+----------------------+
|                      timezone | America/Chicago      | America/Chicago      |
|         currently.temperature | 50.95                | 47.0                 |
|                currently.icon | cloudy               | cloudy               |
|            currently.humidity | 0.5                  | 0.61                 |
|         currently.windBearing | 120                  | 90                   |
</code></pre></div></div>

<p>A reviewer scanning that diff can see at a glance whether a parser change moved a field, dropped one, or drifted from the reference - which is a categorically different experience from diffing raw vendor JSON.</p>

<p>The same move shows up at other layers. SQL behavior is frozen as a normalized statement sequence - literals replaced, comments stripped - so the snapshot captures query <em>shape</em> and count, not volatile values:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">assert_sql</span><span class="p">(</span><span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="n">sql</span> <span class="o">=</span> <span class="p">[]</span>

  <span class="n">subscriber</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">_name</span><span class="p">,</span> <span class="n">_start</span><span class="p">,</span> <span class="n">_finish</span><span class="p">,</span> <span class="n">_id</span><span class="p">,</span> <span class="n">payload</span><span class="p">)</span> <span class="k">do</span>
    <span class="n">sql</span> <span class="o">&lt;&lt;</span> <span class="n">payload</span><span class="p">[</span><span class="ss">:sql</span><span class="p">].</span><span class="nf">split</span><span class="p">(</span><span class="s2">"/*"</span><span class="p">).</span><span class="nf">first</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/\d+/</span><span class="p">,</span> <span class="s2">"?"</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">subscribed</span><span class="p">(</span><span class="n">subscriber</span><span class="p">,</span> <span class="s2">"sql.active_record"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>

  <span class="n">assert_matches_snapshot</span> <span class="n">sql</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span> <span class="o">+</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>And HTTP concurrency behavior is frozen as a spy’s serial/parallel request counts (a request counts as serial if it completed on the same fiber as the previous one), snapshotted as two-line YAML. A change that accidentally serializes a parallel fetch fails a test with a two-line diff.</p>

<p>In each case the code that <em>derives</em> the summary is the investment, and <code class="language-plaintext highlighter-rouge">assert_matches_snapshot</code> is the free part. That division of labor only works when the assertion is free.</p>

<h3 id="coverage-by-metaprogramming">Coverage by metaprogramming</h3>

<p>Because the assertion is one line, generating tests is cheap. The source smoke suite loops over every active data source and every unit system it supports, defining a snapshot test per combination:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Api</span><span class="o">::</span><span class="no">Weather</span><span class="o">::</span><span class="no">ACTIVE_SOURCES</span><span class="p">.</span><span class="nf">excluding</span><span class="p">(</span><span class="no">REFERENCE_SOURCE</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">source</span><span class="o">|</span>
  <span class="n">define_method</span> <span class="s2">"test_compare_</span><span class="si">#{</span><span class="n">source</span><span class="si">}</span><span class="s2">_to_reference"</span> <span class="k">do</span>
    <span class="n">vcr_use_cassette</span><span class="p">(</span><span class="s2">"smoke_test_</span><span class="si">#{</span><span class="n">source</span><span class="si">}</span><span class="s2">_us_forecast"</span><span class="p">)</span> <span class="k">do</span>
      <span class="n">table</span> <span class="o">=</span> <span class="no">Api</span><span class="o">::</span><span class="no">Table</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">sources: </span><span class="p">[</span><span class="n">source</span><span class="p">,</span> <span class="no">REFERENCE_SOURCE</span><span class="p">])</span>
      <span class="n">assert_matches_snapshot</span> <span class="n">table</span><span class="p">.</span><span class="nf">pack</span><span class="p">.</span><span class="nf">to_s</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="no">Api</span><span class="o">::</span><span class="no">Weather</span><span class="o">::</span><span class="no">ACTIVE_SOURCES</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">source</span><span class="o">|</span>
  <span class="no">Api</span><span class="o">::</span><span class="no">Weather</span><span class="p">.</span><span class="nf">source_class</span><span class="p">(</span><span class="n">source</span><span class="p">).</span><span class="nf">supported_source_units</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">units</span><span class="o">|</span>
    <span class="n">define_method</span><span class="p">(</span><span class="s2">"test_</span><span class="si">#{</span><span class="n">source</span><span class="si">}</span><span class="s2">_</span><span class="si">#{</span><span class="n">units</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">do</span>
      <span class="c1"># ...build the output table for this source + units...</span>
      <span class="n">assert_matches_snapshot</span> <span class="n">table</span><span class="p">.</span><span class="nf">pack</span><span class="p">.</span><span class="nf">to_s</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Adding a new data source to the app automatically adds its comparison test, its per-unit-system output tests, and its request-count tests - the loop picks it up, the first run records the snapshots, and the review is the diff of the new files. Nobody writes tests for a new source; they review what the loop recorded.</p>

<h3 id="two-safety-rules">Two safety rules</h3>

<p>Snapshot suites accumulate two failure modes, and the web repo has a written rule for each:</p>

<ul>
  <li><strong>English-only snapshots hide non-English drift.</strong> A localized output asserted only in <code class="language-plaintext highlighter-rouge">en</code> will happily pass while every other language regresses. The repo rule: when snapshotting localized content, assert a non-English language too.</li>
  <li><strong>A fix diff dominated by snapshot churn is a review smell.</strong> The review checklist flags any PR where the snapshot delta outweighs the change: every wire-visible delta must be the point of the change, not a ride-along. If regenerating snapshots produced a hundred changed lines for a one-line fix, either the fix is bigger than claimed or the snapshots are frozen at the wrong altitude.</li>
</ul>

<h2 id="the-decision-no-package-no-dependency">The Decision: No Package, No Dependency</h2>

<p>The obvious move for the iOS side was to adopt the well-known Swift snapshot-testing library. We decided against it - not because there’s anything wrong with it, but because an audit of the repo showed <strong>every risky mechanic was already proven in-house</strong>, and the library’s breadth (image strategies, a trait system) is surface area the text-snapshot use case doesn’t need. It would also have been the first package ever linked into the test target, plus a new entry in the monthly dependency-update cycle. The library’s core module remains the explicit upgrade path if image or SwiftUI-view snapshots are ever wanted.</p>

<p>The audit is the part worth copying, because “can our simulator tests even do this?” is the question that usually pushes teams toward a dependency. Three mechanics, three existing proofs:</p>

<ol>
  <li><strong>Simulator tests can write the host source tree.</strong> The golden recorder already resolves <code class="language-plaintext highlighter-rouge">URL(fileURLWithPath: #filePath)</code> and rewrites a committed Swift file in place from an app-hosted simulator test - the simulator shares the host filesystem, so <code class="language-plaintext highlighter-rouge">#filePath</code> from a test file is a real, writable path into the repo checkout.</li>
  <li><strong>Environment flags reach the test process.</strong> <code class="language-plaintext highlighter-rouge">xcodebuild</code> does not forward arbitrary env vars to tests; it forwards only vars prefixed <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code>, stripping the prefix. The repo’s <code class="language-plaintext highlighter-rouge">bin/unit-test</code> already plumbs the golden-record flag through exactly this mechanism.</li>
  <li><strong>Readable diff-on-fail already existed</strong> on a refactor branch: a first-eight-differing-lines failure message via <code class="language-plaintext highlighter-rouge">Issue.record</code>, naming the file and the regenerate command. Lift it.</li>
</ol>

<p>With all three proven, the only thing missing was the ergonomic layer - which is ~120 lines. That’s the thesis: <strong>port the three properties that make the tool get used; don’t import a library to get mechanics you already have.</strong></p>

<h3 id="naming-match-the-origin-literally">Naming: match the origin literally</h3>

<p>One deliberate ruling worth recording: the new surface matches the Ruby names exactly - <code class="language-plaintext highlighter-rouge">--update-snapshots</code> as the flag, <code class="language-plaintext highlighter-rouge">UPDATE_SNAPSHOTS</code> as the env var, <code class="language-plaintext highlighter-rouge">assertMatchesSnapshot</code> as the helper, a <code class="language-plaintext highlighter-rouge">snapshots/</code> directory mirroring the web repo’s <code class="language-plaintext highlighter-rouge">test/snapshots/</code>. The repo’s existing env flags carry an app-specific prefix; the ruling was to <em>not</em> carry that legacy prefix onto new surface for consistency’s sake. When two codebases share a convention, an engineer (or an agent) moving between them should find the same words. Prefer the better name; don’t propagate churn-avoidance naming into the future.</p>

<h2 id="the-design">The Design</h2>

<p>The whole helper is one file in the test target. The core of it, as designed:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Testing</span>
<span class="kd">import</span> <span class="kt">Foundation</span>

<span class="kd">enum</span> <span class="kt">Snapshots</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">directory</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"snapshots"</span><span class="p">)</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">updating</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="kt">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">environment</span><span class="p">[</span><span class="s">"UPDATE_SNAPSHOTS"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"1"</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">locked</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="kt">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">environment</span><span class="p">[</span><span class="s">"CI"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">lock</span> <span class="o">=</span> <span class="kt">NSLock</span><span class="p">()</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">counters</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Int</span><span class="p">]</span> <span class="o">=</span> <span class="p">[:]</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">nextIndex</span><span class="p">(</span><span class="n">forKey</span> <span class="nv">key</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Int</span> <span class="p">{</span>
        <span class="n">lock</span><span class="o">.</span><span class="nf">lock</span><span class="p">()</span>
        <span class="k">defer</span> <span class="p">{</span> <span class="n">lock</span><span class="o">.</span><span class="nf">unlock</span><span class="p">()</span> <span class="p">}</span>
        <span class="k">let</span> <span class="nv">next</span> <span class="o">=</span> <span class="p">(</span><span class="n">counters</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="p">??</span> <span class="mi">0</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span>
        <span class="n">counters</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">next</span>
        <span class="k">return</span> <span class="n">next</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">sanitized</span><span class="p">(</span><span class="n">_</span> <span class="nv">component</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="n">component</span><span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"[^a-z0-9]+"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"_"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">trimmingCharacters</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="kt">CharacterSet</span><span class="p">(</span><span class="nv">charactersIn</span><span class="p">:</span> <span class="s">"_"</span><span class="p">))</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">assertMatchesSnapshot</span><span class="p">(</span>
    <span class="n">_</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
    <span class="n">named</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span> <span class="o">=</span> <span class="kc">nil</span><span class="p">,</span>
    <span class="nv">filePath</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">,</span>
    <span class="nv">function</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#function</span><span class="p">,</span>
    <span class="nv">sourceLocation</span><span class="p">:</span> <span class="kt">SourceLocation</span> <span class="o">=</span> <span class="err">#</span><span class="n">_sourceLocation</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">suite</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span>
        <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="n">filePath</span><span class="p">)</span><span class="o">.</span><span class="nf">deletingPathExtension</span><span class="p">()</span><span class="o">.</span><span class="n">lastPathComponent</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">test</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span><span class="n">function</span><span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"()"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">""</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">suffix</span> <span class="o">=</span> <span class="n">name</span><span class="o">.</span><span class="nf">map</span><span class="p">(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">sanitized</span><span class="p">)</span>
        <span class="p">??</span> <span class="kt">String</span><span class="p">(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="nf">nextIndex</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"</span><span class="se">\(</span><span class="n">suite</span><span class="se">)</span><span class="s">/</span><span class="se">\(</span><span class="n">test</span><span class="se">)</span><span class="s">"</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">snapshotURL</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="n">directory</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="n">suite</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">test</span><span class="se">)</span><span class="s">__</span><span class="se">\(</span><span class="n">suffix</span><span class="se">)</span><span class="s">.snap.txt"</span><span class="p">)</span>

    <span class="k">if</span> <span class="o">!</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">updating</span><span class="p">,</span>
       <span class="k">let</span> <span class="nv">recorded</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">String</span><span class="p">(</span><span class="nv">contentsOf</span><span class="p">:</span> <span class="n">snapshotURL</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">recorded</span> <span class="o">==</span> <span class="n">value</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span>
            <span class="s">"output drifted from the snapshot — regenerate with "</span> <span class="o">+</span>
            <span class="s">"bin/unit-test --update-snapshots, then review every changed line. "</span> <span class="o">+</span>
            <span class="s">"First differences:</span><span class="se">\n\(</span><span class="nf">firstDifferences</span><span class="p">(</span><span class="n">recorded</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span><span class="se">)</span><span class="s">"</span><span class="p">,</span>
            <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span><span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="k">guard</span> <span class="o">!</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">locked</span> <span class="k">else</span> <span class="p">{</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span>
            <span class="s">"snapshot is missing or an update was requested, but snapshots "</span> <span class="o">+</span>
            <span class="s">"are locked under CI — record locally and commit the file"</span><span class="p">,</span>
            <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span><span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="k">try</span><span class="p">?</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="nf">createDirectory</span><span class="p">(</span>
        <span class="nv">at</span><span class="p">:</span> <span class="n">snapshotURL</span><span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">(),</span>
        <span class="nv">withIntermediateDirectories</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
    <span class="k">try</span><span class="p">?</span> <span class="n">value</span><span class="o">.</span><span class="nf">write</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">snapshotURL</span><span class="p">,</span> <span class="nv">atomically</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Reading it against the three properties: the assertion is one line at the call site; the file path is derived from suite + test with a per-test auto-incrementing counter (<code class="language-plaintext highlighter-rouge">__1</code>, <code class="language-plaintext highlighter-rouge">__2</code>, …); a missing file records and passes on first run; <code class="language-plaintext highlighter-rouge">UPDATE_SNAPSHOTS=1</code> re-records everything; and under <code class="language-plaintext highlighter-rouge">CI</code>, both the missing-file path and update mode fail instead of writing - the same lock the gem calls <code class="language-plaintext highlighter-rouge">lock_snapshots</code>. Values are raw strings in <code class="language-plaintext highlighter-rouge">.snap.txt</code> files rather than the gem’s <code class="language-plaintext highlighter-rouge">.snap.yaml</code>, because the YAML serializer is deliberately not ported: callers canonicalize to a string (pretty-printed JSON, joined lines), and structured-value overloads wait until a real consumer needs one.</p>

<p>The flag side is a few lines in the existing test runner script, using the <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code> mechanism already proven for the golden recorder:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Snapshot update mode: bin/unit-test --update-snapshots (or UPDATE_SNAPSHOTS=1)</span>
<span class="c"># rewrites every snapshot the run touches; review the snapshots/ diff before</span>
<span class="c"># committing. CI is forwarded so the helper can lock snapshots.</span>
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"--update-snapshots"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">UPDATE_SNAPSHOTS</span><span class="o">=</span>1
  <span class="k">fi
done
if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$UPDATE_SNAPSHOTS</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"1"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">export </span><span class="nv">TEST_RUNNER_UPDATE_SNAPSHOTS</span><span class="o">=</span>1
<span class="k">fi
if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$CI</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">export </span><span class="nv">TEST_RUNNER_CI</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CI</span><span class="s2">"</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>Note the last block: the CI lock does not work by accident. GitHub Actions sets <code class="language-plaintext highlighter-rouge">CI</code> in the runner shell, but only <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code>-prefixed vars cross into the test process - so plain <code class="language-plaintext highlighter-rouge">CI</code> never arrives unless the script forwards it explicitly. Miss that and the lock silently never engages, which is the worst kind of safety feature.</p>

<h3 id="what-swift-testing-changes">What Swift Testing changes</h3>

<p>A port is not a transliteration; the destination framework’s semantics reshape three details.</p>

<p><strong>Parallel by default.</strong> Minitest runs a suite’s tests in one process where a simple counter suffices. Swift Testing runs tests in parallel by default, so the <code class="language-plaintext highlighter-rouge">__N</code> auto-numbering counter must be a lock-guarded dictionary keyed by suite + test. Calls <em>within</em> one test are sequential, so numbering stays deterministic per test - the lock only defends the map against concurrent tests touching it.</p>

<p><strong>Parameterized tests collide.</strong> <code class="language-plaintext highlighter-rouge">@Test(arguments:)</code> runs one function many times, and every invocation shares the same <code class="language-plaintext highlighter-rouge">#function</code> string - so auto-numbering across parameterized cases would depend on execution order. Parameterized tests must pass an explicit <code class="language-plaintext highlighter-rouge">named:</code> argument; that’s a documented requirement on the helper rather than runtime machinery.</p>

<p><strong>One spelling flagged for verification.</strong> The <code class="language-plaintext highlighter-rouge">sourceLocation: SourceLocation = #_sourceLocation</code> default argument - which makes failures point at the caller’s line rather than the helper’s - is the documented pattern for custom assertion helpers, but the plan explicitly marks it <code class="language-plaintext highlighter-rouge">Verify:</code> against the toolchain’s Swift Testing version before implementation. Design records should carry their own uncertainty; an implementing agent that hits a compile error on that line should find the plan already told it this might happen.</p>

<h3 id="what-the-helper-deliberately-does-not-replace">What the helper deliberately does not replace</h3>

<p>The existing golden-table system stays. Its value is compile-enforced exhaustiveness - the table is generated Swift covering <code class="language-plaintext highlighter-rouge">allCases</code> of language x format intent, so a newly added case <em>cannot</em> be silently missing from coverage. File snapshots can’t match that property, and the plan records keeping it as a decision, not an oversight. The helper is for everything that today isn’t worth a bespoke recorder: sync payload shapes, widget timeline dumps, notification content, and draining a couple of standalone validator tools into env-gated report-generating tests.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Identify why the tool gets used before deciding what to port.</strong> For snapshot testing it’s three properties: drop-in assertion, automatic naming, one-flag update. A port that delivers those in ~120 lines beats a dependency that delivers them plus a hundred things you don’t need.</li>
  <li><strong>Audit for proven mechanics before reaching for a library.</strong> The risky parts - writing the host filesystem from a simulator test, env plumbing through <code class="language-plaintext highlighter-rouge">xcodebuild</code>, readable diff-on-fail - all had existing in-repo proofs. The dependency would have bought ergonomics, and ergonomics are the cheap part.</li>
  <li><strong>The git diff is the review artifact.</strong> First-run auto-record and one-flag update only stay safe because the culture treats a snapshot diff as a behavior change that gets read line by line, like a copy change. The mechanism and the culture ship together or not at all.</li>
  <li><strong>Lock CI, and verify the lock’s plumbing.</strong> CI must never silently bless a new snapshot - and in an <code class="language-plaintext highlighter-rouge">xcodebuild</code> world, the <code class="language-plaintext highlighter-rouge">CI</code> variable doesn’t reach the test process unless you forward it under the <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code> prefix. A lock that never engages looks identical to a lock that works.</li>
  <li><strong>Snapshot derived summaries, not raw payloads.</strong> Comparison tables, normalized SQL sequences, request-count YAML: build the artifact to be diffed by a human, and spend your effort in the deriving code, not the asserting code.</li>
  <li><strong>Cheap assertions compound through metaprogramming.</strong> When the assertion is one line, a loop over your adapters gives every new one full snapshot coverage for free - the review is the diff of the recorded files.</li>
  <li><strong>Adopt the origin’s names literally.</strong> Same flag, same env var, same directory name across both codebases. Don’t carry a legacy prefix onto new surface out of habit.</li>
  <li><strong>Port the semantics gap explicitly.</strong> Parallel-by-default and parameterized tests are exactly the kind of thing a naive transliteration gets wrong. Write down what the destination framework changes, and flag the parts you haven’t verified as unverified.</li>
  <li><strong>A design record is a shippable artifact.</strong> The plan carries the complete helper source, the runner patch, non-goals, and its open questions - so implementation is a pick-up task for whoever (or whatever) gets there when the trigger fires, not a re-derivation.</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “see recent work in ~/Code/helloweather, perhaps a blog post about our opus 4.8 agents and why we decided to do that? perhaps something about the swift testing + snapshots inspired by minitest-snapshots? anything else? bring me a list of potential post ideas for review.”</p>

<p><strong>Prompt 2:</strong> “skip 4, 5, 6, 9 but create posts for each of the others in the 1-9 list. also add Four Answers to One Question, and Write the Rule, Not the Story – show me a concise version of your plan and then I can approve” — then “proceed, one pr per post”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and skill files, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="testing" /><category term="swift" /><category term="ios" /><category term="ruby" /><category term="snapshot-testing" /><category term="workflow" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Sync Only What the Watch Reads: An Allowlist Inversion</title><link href="https://trevorturk.github.io/watch-sync-allowlist/" rel="alternate" type="text/html" title="Sync Only What the Watch Reads: An Allowlist Inversion" /><published>2026-07-29T15:40:00+00:00</published><updated>2026-07-29T15:40:00+00:00</updated><id>https://trevorturk.github.io/watch-sync-allowlist</id><content type="html" xml:base="https://trevorturk.github.io/watch-sync-allowlist/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p><a href="https://helloweather.com">Hello Weather</a> syncs settings from the phone to the Apple Watch over WatchConnectivity. The implementation looked reasonable when it was written:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">Keys</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">temperatureUnit</span>
    <span class="k">case</span> <span class="n">displayLanguage</span>
    <span class="k">case</span> <span class="n">memberEntitlement</span>
    <span class="c1">// ...and eighty-some more</span>
<span class="p">}</span>

<span class="kd">nonisolated</span> <span class="k">var</span> <span class="nv">keys</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
    <span class="kt">Keys</span><span class="o">.</span><span class="n">allCases</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">rawValue</span> <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">getDictionaryRepresentation</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">]</span> <span class="p">{</span>
    <span class="n">store</span><span class="o">.</span><span class="nf">dictionaryRepresentation</span><span class="p">()</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">_</span><span class="p">)</span> <span class="k">in</span> <span class="n">keys</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One enum listed every key in the shared app-group <code class="language-plaintext highlighter-rouge">UserDefaults</code>. That same enum silently doubled as the watch sync manifest. Every key registered for <em>storage</em> was automatically enrolled in <em>transfer</em>, and that dictionary shipped on every <code class="language-plaintext highlighter-rouge">updateApplicationContext</code> and every complication refresh.</p>

<p>Nobody decided this. It’s what <code class="language-plaintext highlighter-rouge">CaseIterable</code> does when you use it as a schema.</p>

<p>Four years of feature work later, the payload was about 90 keys and rising. Riding along on every complication update:</p>

<ul>
  <li>A purchase-transaction cache that could hold thousands of records</li>
  <li>Saved and recent location arrays the watch <strong>cannot decode</strong> — the manager that models them isn’t compiled into the watch target</li>
  <li>Push tokens, delivered-alert bookkeeping, and notification scheduling state</li>
  <li>Radar map state, onboarding progress, review-nag timestamps, migration bookkeeping</li>
  <li>A per-process fetch mutex — a timestamp meaning “a fetch is running <em>in this process</em>” — copied across devices, where it can suppress a fetch on the other one</li>
</ul>

<p>The trigger was a new preview cache. Adding a normal storage key for a phone-only screen quietly enlisted it into every watch transfer. That’s when the shape of the bug became obvious: <strong>storage registration was implying sync enrollment</strong>, and there was no place in the codebase where anyone was asked to decide.</p>

<h2 id="the-audit">The Audit</h2>

<p>The fix isn’t interesting. The method is.</p>

<p>Before writing any code, we mapped every one of the ~90 keys to actual reads and writes <strong>in the watch-compiled source set</strong> — not “does the watch have this file”, but “is this file a member of the watch target”, using the target’s file-membership exceptions in <code class="language-plaintext highlighter-rouge">project.pbxproj</code> as ground truth. Each key got one of three verdicts: read on watch, written on watch, or neither.</p>

<p>The result: roughly 25 keys belonged. About 50 were pure payload — bytes that had never been read on the other side of the connection. The rest were judgment calls that only surfaced <em>because</em> we were forced to write down a reason for each key.</p>

<p>Three findings that a quick eyeball would have gotten wrong, in both directions:</p>

<p><strong>Six notification toggles looked phone-only. They aren’t.</strong> Each maps to an iOS notification category, so excluding them was the obvious call. But a helper ORs all six into a single “notifications on?” boolean, and the watch’s location service branches on it — <code class="language-plaintext highlighter-rouge">requestAlwaysAuthorization()</code> versus <code class="language-plaintext highlighter-rouge">requestWhenInUseAuthorization()</code>. Dropping six booleans would have silently downgraded the watch’s location authorization request for every user with notifications enabled. Six bytes, real consequence.</p>

<p><strong>A key written on the watch but never read there.</strong> The watch stores the device location, then geocodes its own copy anyway; every reader of the stored value is phone-only UI. It’s a genuine trim candidate — and we kept it, because dropping it is an unforced behavior change with no upside. “Unused” and “safe to remove in this PR” are different questions.</p>

<p><strong>A flag whose only reader is compiled out today.</strong> The feature it gates isn’t available on watchOS yet, so the read sits inside a <code class="language-plaintext highlighter-rouge">#if canImport(...)</code> that never fires there. But the code path that consults it runs unconditionally in watch context, so the key goes live the day the framework arrives. Enrolled ahead of time.</p>

<p>We also wrote down the high-risk keys explicitly, because their failure modes are silent rather than loud: entitlement flags, the language key (a localization helper reads the store directly, so losing it reverts the whole watch to English), the chart-style key (a style resolver reads the store directly, bypassing the settings manager), and the API parameter keys the watch uses to build its own forecast URL.</p>

<h2 id="the-inversion">The Inversion</h2>

<p>Two mechanics of the old protocol shaped the design, and both had to be verified rather than assumed.</p>

<p><strong>The payload unions registered defaults.</strong> <code class="language-plaintext highlighter-rouge">dictionaryRepresentation()</code> returns every key that has a <code class="language-plaintext highlighter-rouge">registerDefaults</code> entry, whether or not a user ever touched it. The sync wasn’t “everything the user changed” — it was “everything the app ever declared.”</p>

<p><strong>Absence meant deletion.</strong> The apply side cleared <em>every</em> known key before writing the incoming values:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">func</span> <span class="nf">setDictionaryRepresentation</span><span class="p">(</span><span class="n">_</span> <span class="nv">dictionary</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">key</span> <span class="k">in</span> <span class="n">keys</span> <span class="p">{</span>
        <span class="n">store</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">)</span>   <span class="c1">// wipe everything...</span>
    <span class="p">}</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">val</span><span class="p">)</span> <span class="k">in</span> <span class="n">dictionary</span> <span class="p">{</span>
        <span class="n">store</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">val</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">)</span>       <span class="c1">// ...then restore what arrived</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A key missing from the payload was erased on the watch. And on this codebase, absence has semantics:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">paidWatch</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">store</span><span class="o">.</span><span class="nf">object</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">memberEntitlement</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">true</span>          <span class="c1">// no entitlement key at all: treat as paid</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">paid</span> <span class="o">||</span> <span class="n">legacyMember</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">return true</code> is deliberate — it keeps the watch app working during the window before the first sync lands, rather than flashing a paywall at a paying customer. But combine it with wipe-then-apply and you get a landmine: <strong>any allowlist that omits the entitlement key hands out the paid watch app for free.</strong> Get the polarity backwards on a different key and you revoke it from someone who paid. A one-line mistake in a list of key names, and the app’s business model is a coin flip.</p>

<p>So the allowlist and the deletion semantics had to change in the same commit. The shipped shape:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">Keys</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="c1">// ...all ~90 storage keys...</span>

    <span class="c1">// Watch sync is opt-in: see plans/watch-sync-allowlist.md before adding here.</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">synced</span><span class="p">:</span> <span class="kt">Set</span><span class="o">&lt;</span><span class="kt">Keys</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">[</span>
        <span class="o">.</span><span class="n">weather</span><span class="p">,</span>
        <span class="o">.</span><span class="n">selectedLocation</span><span class="p">,</span>
        <span class="o">.</span><span class="n">memberEntitlement</span><span class="p">,</span>
        <span class="o">.</span><span class="n">legacyMember</span><span class="p">,</span>
        <span class="o">.</span><span class="n">temperatureUnit</span><span class="p">,</span>
        <span class="o">.</span><span class="n">displayLanguage</span><span class="p">,</span>
        <span class="o">.</span><span class="n">chartStyle</span><span class="p">,</span>
        <span class="c1">// ...36 in total</span>
    <span class="p">]</span>
<span class="p">}</span>

<span class="kd">nonisolated</span> <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">syncedKeys</span> <span class="o">=</span> <span class="kt">Set</span><span class="p">(</span><span class="kt">Keys</span><span class="o">.</span><span class="n">synced</span><span class="o">.</span><span class="nf">map</span><span class="p">(\</span><span class="o">.</span><span class="n">rawValue</span><span class="p">))</span>

<span class="kd">func</span> <span class="nf">syncPayload</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">]</span> <span class="p">{</span>
    <span class="n">store</span><span class="o">.</span><span class="nf">dictionaryRepresentation</span><span class="p">()</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">_</span><span class="p">)</span> <span class="k">in</span> <span class="k">Self</span><span class="o">.</span><span class="n">syncedKeys</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>

<span class="kd">nonisolated</span> <span class="kd">func</span> <span class="nf">applySyncPayload</span><span class="p">(</span><span class="n">_</span> <span class="nv">payload</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">key</span> <span class="k">in</span> <span class="k">Self</span><span class="o">.</span><span class="n">syncedKeys</span> <span class="p">{</span>
        <span class="n">store</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">val</span><span class="p">)</span> <span class="k">in</span> <span class="n">payload</span> <span class="k">where</span> <span class="k">Self</span><span class="o">.</span><span class="n">syncedKeys</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">store</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">val</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">key</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three changes worth separating, because each does distinct work:</p>

<ol>
  <li><strong>The allowlist is opt-in.</strong> Adding a storage key no longer enrolls it in anything. Enrollment is a deliberate, reviewed act with a comment pointing at the reasoning.</li>
  <li><strong>Clear only synced keys.</strong> Exclusion now means “watch-local,” never “delete.” Unsynced keys the watch owns — its own fetch mutex, its own local bookkeeping — survive a sync instead of being wiped by it.</li>
  <li><strong>Apply only synced keys.</strong> The filter runs on the receiving side too. During the version-skew window, an older phone still sends the full 90-key payload; without the receive-side filter it would smuggle the excluded keys straight back onto the watch.</li>
</ol>

<p>We also dropped <code class="language-plaintext highlighter-rouge">CaseIterable</code> from the enum. There were zero remaining uses, and <code class="language-plaintext highlighter-rouge">allCases</code> was the exact footgun being removed — leaving it available is leaving the trap armed for whoever needs “a list of all the keys” next.</p>

<p>The allowlist is covered by tests, and the interesting one is not the count assertion:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span><span class="p">(</span><span class="s">"Local-only keys stay out of the payload"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">localOnlyKeysExcluded</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">excluded</span><span class="p">:</span> <span class="p">[</span><span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
        <span class="o">.</span><span class="n">debugMode</span><span class="p">,</span> <span class="o">.</span><span class="n">migratedVersion</span><span class="p">,</span> <span class="o">.</span><span class="n">previewCache</span><span class="p">,</span>
        <span class="o">.</span><span class="n">fetchInProgressAt</span><span class="p">,</span> <span class="o">.</span><span class="n">savedPlaces</span><span class="p">,</span> <span class="o">.</span><span class="n">recentPlaces</span><span class="p">,</span>
    <span class="p">]</span>
    <span class="k">for</span> <span class="n">key</span> <span class="k">in</span> <span class="n">excluded</span> <span class="p">{</span>
        <span class="cp">#expect(SavedDataManager.Keys.synced.contains(key) == false,</span>
                <span class="s">"</span><span class="se">\(</span><span class="n">key</span><span class="o">.</span><span class="n">rawValue</span><span class="se">)</span><span class="s"> must not sync to the watch"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">@Test</span><span class="p">(</span><span class="s">"syncPayload filters to the allowlist"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">syncPayloadFiltersToAllowlist</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">store</span> <span class="o">=</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">store</span>
    <span class="k">let</span> <span class="nv">marker</span> <span class="o">=</span> <span class="s">"testOnlyUnknownKey"</span>
    <span class="n">store</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="s">"junk"</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="n">marker</span><span class="p">)</span>
    <span class="k">defer</span> <span class="p">{</span> <span class="n">store</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="n">marker</span><span class="p">)</span> <span class="p">}</span>

    <span class="k">let</span> <span class="nv">payload</span> <span class="o">=</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">syncPayload</span><span class="p">()</span>

    <span class="cp">#expect(payload[marker] == nil)</span>
    <span class="cp">#expect(payload[SavedDataManager.Keys.temperatureUnit.rawValue] != nil)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A count test says “36.” The exclusion test says <em>why</em> — it names the keys whose presence would be a bug and fails with a sentence a future reader can act on.</p>

<h2 id="the-bug-the-audit-found">The Bug the Audit Found</h2>

<p>Halfway through mapping keys to reads, one key came back with an answer that didn’t fit the categories: the data-source preference was <strong>written on the watch</strong>.</p>

<p>Which raised an immediate question, because the sync is one-directional. The watch’s <code class="language-plaintext highlighter-rouge">sync()</code> only reloads complications; its <code class="language-plaintext highlighter-rouge">requestSync()</code> <em>pulls</em> phone state. There is no watch-to-phone data path at all.</p>

<p>So a source picked on the watch was reverted by the next phone sync. Always. The write went into the shared store, looked like it worked, and got wiped the next time the phone said anything.</p>

<p>The reflex is to build the missing direction. We didn’t, for two reasons.</p>

<p>First, we checked whether anyone could actually reach the picker — and they couldn’t. The picker view had been unreachable since 2024, when the button that presented it was turned into a display-only label. The bug was real but latent: dead code with a live-looking failure mode, sitting in the repo for nearly two years.</p>

<p>Second, even if it <em>had</em> been reachable, “add a watch-to-phone settings channel” is a transport project, not a bug fix. Building a reverse sync channel to serve one picker nobody had asked for would have been the tail wagging the dog.</p>

<p>So we deleted the picker. The read-only source label stays; the dead view is gone. The restore path is written down instead of built — watch source selection is a phase of the watch-parity plan, explicitly gated on the watch-to-phone settings channel that a different plan owns, and if it comes back it comes back with the Automatic option the phone has.</p>

<p><strong>Delete the UI that lies.</strong> A control that appears to work and silently reverts is worse than no control. When you find one, the choice is fix the plumbing or remove the control — and removing it is legitimate, as long as you write down what restoring it would require.</p>

<h2 id="results">Results</h2>

<p>The payload went from ~90 keys to 36 — and the excluded set is where the weight was: the transaction cache, both location arrays, the delivered-alerts record, the preview cache. Transfers on the complication path carry the settings the watch reads and nothing else.</p>

<p>The entitlement landmine is defused. The allowlist can no longer wipe a key it doesn’t list, so an omission is now a missing-setting bug instead of a free-paid-app bug. That’s the change that mattered most: not the bytes, but converting a silent revenue failure into a visible, boring one.</p>

<p>Two riders came out of the audit for free. A key that had been a hardcoded literal since 2024 and never touched the store at all was deleted. And the watch, on first touch of a shared manager, was writing an iOS paywall timestamp into the shared store — harmless under wipe-everything semantics because the next sync erased it, but permanent under clear-only-synced. Changing the deletion rule turned a self-correcting accident into a persistent one, so it’s now compiled out with <code class="language-plaintext highlighter-rouge">#if !os(watchOS)</code>.</p>

<p>One consequence we accepted rather than fixed: watches that already synced the ~50 excluded keys keep them frozen in their local store forever. Nothing sends them and nothing clears them anymore, so the win is transfer-only. It’s correctness-neutral — none of them is read on the watch — and a one-time purge is written down as an optional follow-up rather than shipped speculatively.</p>

<p>The last piece is documentation, because the inversion has a cost that only shows up months later. Opt-in means a new key that the watch genuinely needs will silently not arrive. So the feature-flags skill grew a sixth touch point:</p>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>Pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Watch sync</td>
      <td><strong>Only if watch-compiled code reads the flag</strong>: enroll it in <code class="language-plaintext highlighter-rouge">Keys.synced</code>, else skip — keys do NOT sync automatically</td>
    </tr>
  </tbody>
</table>

<p>When you invert a default, the new default’s failure mode moves. Denylist fails by shipping too much. Allowlist fails by shipping too little — quietly, in a target you’re not looking at. That’s a strictly better failure to have, and it still has to be written down where the next person will hit it.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>“Absence means deletion” is a dangerous protocol default.</strong> Wipe-then-apply is the easy way to make a sync converge, and it turns every omission into a destructive operation. Make deletion explicit — a tombstone, an explicit key list, anything — or scope the wipe to exactly the keys you’re authoritative for.</li>
  <li><strong>Check what absence <em>means</em> on the receiving side.</strong> A missing key is rarely neutral. Ours meant “paid.” Somewhere in your codebase, a <code class="language-plaintext highlighter-rouge">nil</code> check has a default that was written for a different situation than the one your sync protocol creates.</li>
  <li><strong>Allowlist over denylist for anything crossing a device boundary.</strong> Payloads only grow, and they grow by accident — by someone adding a storage key for an unrelated screen. Under an allowlist that’s a no-op; under a denylist it’s a silent enrollment.</li>
  <li><strong>Audit first, decide second.</strong> Mapping every key to real reads took an afternoon and produced three findings that contradicted the obvious answer in both directions. Half the value wasn’t the list — it was being forced to write a reason next to each key.</li>
  <li><strong>Ground the audit in the build system, not the file tree.</strong> “Does the watch read this?” is a question about target membership. Grepping the repo would have gotten several keys wrong.</li>
  <li><strong>When a one-way channel is pretending to be two-way, delete the UI that lies.</strong> Don’t build the missing direction to justify a control nobody uses. Remove the control, write down what restoring it requires, and let the transport work happen when something actually needs it.</li>
  <li><strong>Removing the footgun means removing the tool.</strong> Dropping <code class="language-plaintext highlighter-rouge">CaseIterable</code> was the point of the change, not a tidy-up. As long as <code class="language-plaintext highlighter-rouge">allCases</code> exists, someone will reach for it.</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “it’s been a while since we added any blog posts, see recent work in the ~/Code/helloweather projects, dispatch opus agents to search for interesting stuff that we’ve done since the last blog post, perhaps one or more agents per repo, then review and consider and come up with a proposed list of blog posts we might consider.”</p>

<p><strong>Prompt 2:</strong> “draft posts for [the approved shortlist] – create one pr for the repo main / skills update we just did, then one pr per post for the approved list”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and plan docs, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="watchos" /><category term="sync" /><category term="ios" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Deleting the Workarounds: Fixing Every Digital Crown Bug at Once</title><link href="https://trevorturk.github.io/deleting-the-workarounds/" rel="alternate" type="text/html" title="Deleting the Workarounds: Fixing Every Digital Crown Bug at Once" /><published>2026-07-29T15:10:00+00:00</published><updated>2026-07-29T15:10:00+00:00</updated><id>https://trevorturk.github.io/deleting-the-workarounds</id><content type="html" xml:base="https://trevorturk.github.io/deleting-the-workarounds/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>The Apple Watch app for <a href="https://helloweather.com">Hello Weather</a> had a Digital Crown
that didn’t reliably scroll.</p>

<p>Not “never scrolled” — that would have been easy. It scrolled <em>sometimes</em>. After a
finger drag but not before one. From the app list but not from a complication. It went
dead after a refresh, and dead again after dismissing an error alert. It never worked at
all inside pushed detail screens, or on the root screen after you navigated back.</p>

<p>One customer report finally described the mechanism instead of the symptom: on a large
watch, the crown was never dead. Turning it silently flung the horizontal “Coming up”
hourly strip about thirty hours sideways. On a smaller watch the strip sits below the
fold, so the same behavior just reads as “the crown does nothing.”</p>

<p>That single detail reframed everything. The crown wasn’t unfocused. It was focused on
the wrong thing.</p>

<h2 id="the-workaround-pile">The Workaround Pile</h2>

<p>Over seven months, each symptom got its own patch. The vertical scroll view got
<code class="language-plaintext highlighter-rouge">@FocusState</code> and a focus-on-appear. Then alerts appearing at launch caused a focus
race, so an <code class="language-plaintext highlighter-rouge">onChange</code> refocus was added. Then focus didn’t stick after an idle launch,
so the single retry became a ladder of three, with a defocus-then-refocus reset in the
middle. Then a <code class="language-plaintext highlighter-rouge">scenePhase</code> handler, because returning from the background lost focus
again. Then an alert-dismissal handler, because error alerts stole it too.</p>

<p>By July, <code class="language-plaintext highlighter-rouge">ForecastView</code> looked like this:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">ForecastView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@Environment</span><span class="p">(\</span><span class="o">.</span><span class="n">scenePhase</span><span class="p">)</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">scenePhase</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">focusRetryIntervalsNanoseconds</span><span class="p">:</span> <span class="p">[</span><span class="kt">UInt64</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">150_000_000</span><span class="p">,</span> <span class="mi">500_000_000</span><span class="p">]</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">focusResetDelayNanoseconds</span><span class="p">:</span> <span class="kt">UInt64</span> <span class="o">=</span> <span class="mi">25_000_000</span>

    <span class="kd">@FocusState</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isScrollViewFocused</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">focusRetryTask</span><span class="p">:</span> <span class="kt">Task</span><span class="o">&lt;</span><span class="kt">Void</span><span class="p">,</span> <span class="kt">Never</span><span class="o">&gt;</span><span class="p">?</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isViewVisible</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="c1">// ...</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">refocusScrollView</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="n">isViewVisible</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">focusRetryTask</span><span class="p">?</span><span class="o">.</span><span class="nf">cancel</span><span class="p">()</span>

        <span class="n">focusRetryTask</span> <span class="o">=</span> <span class="kt">Task</span> <span class="p">{</span> <span class="kd">@MainActor</span> <span class="k">in</span>
            <span class="k">for</span> <span class="n">interval</span> <span class="k">in</span> <span class="k">Self</span><span class="o">.</span><span class="n">focusRetryIntervalsNanoseconds</span> <span class="p">{</span>
                <span class="k">try</span><span class="p">?</span> <span class="k">await</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">sleep</span><span class="p">(</span><span class="nv">nanoseconds</span><span class="p">:</span> <span class="n">interval</span><span class="p">)</span>
                <span class="k">guard</span> <span class="kt">Task</span><span class="o">.</span><span class="n">isCancelled</span> <span class="o">==</span> <span class="kc">false</span><span class="p">,</span> <span class="n">isViewVisible</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
                <span class="k">await</span> <span class="nf">setScrollViewFocus</span><span class="p">()</span>
            <span class="p">}</span>

            <span class="n">focusRetryTask</span> <span class="o">=</span> <span class="kc">nil</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">setScrollViewFocus</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
        <span class="n">isScrollViewFocused</span> <span class="o">=</span> <span class="kc">false</span>

        <span class="k">try</span><span class="p">?</span> <span class="k">await</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">sleep</span><span class="p">(</span><span class="nv">nanoseconds</span><span class="p">:</span> <span class="k">Self</span><span class="o">.</span><span class="n">focusResetDelayNanoseconds</span><span class="p">)</span>
        <span class="k">guard</span> <span class="kt">Task</span><span class="o">.</span><span class="n">isCancelled</span> <span class="o">==</span> <span class="kc">false</span><span class="p">,</span> <span class="n">isViewVisible</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">isScrollViewFocused</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three magic intervals. A defocus/refocus cycle with a 25ms gap. A cancellable task
tracking view visibility by hand. Four separate triggers calling into it.</p>

<p>Every pushed detail screen carried its own three-line version of the same idea:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@FocusState</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isScrollViewFocused</span><span class="p">:</span> <span class="kt">Bool</span>

<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kt">ScrollView</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">,</span> <span class="nv">showsIndicators</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// ...</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">focusable</span><span class="p">()</span>
    <span class="o">.</span><span class="nf">focused</span><span class="p">(</span><span class="err">$</span><span class="n">isScrollViewFocused</span><span class="p">)</span>
    <span class="o">.</span><span class="n">onAppear</span> <span class="p">{</span> <span class="n">isScrollViewFocused</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>None of it worked completely. Each patch fixed the case it was written for and left the
others alone.</p>

<h2 id="finding-the-real-causes">Finding the Real Causes</h2>

<p>Two things broke the stalemate.</p>

<p><strong>We stopped trusting the folklore that the simulator can’t reproduce crown bugs.</strong> It
can. The trick is that plain scroll-wheel events don’t register as crown input — you
have to post continuous-phase scroll events (<code class="language-plaintext highlighter-rouge">began</code>/<code class="language-plaintext highlighter-rouge">changed</code>/<code class="language-plaintext highlighter-rouge">ended</code>), validated
against the watch’s own Settings app as a control. That turned every experiment from a
device session into a simulator run.</p>

<p><strong>And we tested with crown-only input, before any touch.</strong> Every prior QA pass had
tapped or dragged something first, which silently rebound the crown and hid the launch
state. Two structural findings fell out immediately.</p>

<h3 id="cause-1-nested-scrollables-compete-for-crown-ownership">Cause 1: nested scrollables compete for crown ownership</h3>

<p>watchOS binds the Digital Crown to exactly one scrollable at a time. A horizontal
<code class="language-plaintext highlighter-rouge">ScrollView</code> nested inside the vertical one is still a scroll view — and at launch, it
won. Crown rotation drove the hourly strip sideways instead of scrolling the page.</p>

<p>The important part: <strong><code class="language-plaintext highlighter-rouge">.focusable(false)</code> does not prevent this.</strong> Neither does removing
the strip’s programmatic <code class="language-plaintext highlighter-rouge">scrollTo</code>. The nested scroll view claimed the crown merely by
existing. Every launch-path workaround was fighting for ownership that the layout was
handing away.</p>

<h3 id="cause-2-explicit-focus-management-suppresses-native-crown-routing">Cause 2: explicit focus management suppresses native crown routing</h3>

<p>This one was more embarrassing. The detail screens had never crown-scrolled — a
long-standing bug that survived eight-plus experiments across two research sessions,
including <code class="language-plaintext highlighter-rouge">@FocusState</code> reclaim, <code class="language-plaintext highlighter-rouge">.focusable(interactions: .edit)</code>, <code class="language-plaintext highlighter-rouge">NavigationStack</code>
migration, and <code class="language-plaintext highlighter-rouge">.id</code>-nonce view recreation.</p>

<p>All of those experiments assumed the focus machinery was part of the solution. It was
the problem. Remove <code class="language-plaintext highlighter-rouge">.focusable()</code>/<code class="language-plaintext highlighter-rouge">.focused()</code>/<code class="language-plaintext highlighter-rouge">onAppear</code>-focus from a pushed
<code class="language-plaintext highlighter-rouge">ScrollView</code> and watchOS routes the crown to it natively, immediately, in every case.
There’s a signal buried in that: Apple’s own documented crown examples never put a raw
focusable <code class="language-plaintext highlighter-rouge">ScrollView</code> in the happy path. When your configuration appears nowhere in the
platform’s sample code, that’s evidence, not coincidence.</p>

<p>We did the upstream homework before committing to a rewrite: OS release notes, beta
notes, and the year’s SwiftUI session content contained zero crown focus-ownership
changes, while adjacent crown and scroll-view bugs <em>were</em> being triaged. The same
complication-launch failure reproduced in a first-party Apple app. Verdict: nothing is
coming from the platform. Fix it ourselves.</p>

<h2 id="the-fix-is-a-deletion">The Fix Is a Deletion</h2>

<p>The change removed both root causes and every workaround built on top of them:</p>

<ul>
  <li>The nested horizontal <code class="language-plaintext highlighter-rouge">ScrollView</code> became a clipped <code class="language-plaintext highlighter-rouge">HStack</code> panned by a
<code class="language-plaintext highlighter-rouge">DragGesture</code>.</li>
  <li>With no competing scrollable, the root <code class="language-plaintext highlighter-rouge">ScrollView</code> bound the crown natively —
so all the focus machinery went, along with the <code class="language-plaintext highlighter-rouge">scenePhase</code> and alert refocus
handlers.</li>
  <li>The three detail screens lost their <code class="language-plaintext highlighter-rouge">.focusable()</code>/<code class="language-plaintext highlighter-rouge">.focused()</code>/focus-on-appear
blocks.</li>
</ul>

<p>Net: 391 lines added, 122 removed — and nearly all of the additions are the hand-rolled
pan, not new crown logic. <code class="language-plaintext highlighter-rouge">ForecastView</code> ended up simpler than it had been <em>before</em> the
first workaround shipped.</p>

<h3 id="hand-rolling-the-pan">Hand-rolling the pan</h3>

<p>Replacing a <code class="language-plaintext highlighter-rouge">ScrollView</code> means replacing its physics. The strip tracks an offset, clamps
it to content width, and applies UIScrollView-style exponential velocity decay after
release:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="k">var</span> <span class="nv">horizontalDragGesture</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">Gesture</span> <span class="p">{</span>
    <span class="kt">DragGesture</span><span class="p">(</span><span class="nv">minimumDistance</span><span class="p">:</span> <span class="mi">8</span><span class="p">)</span>
        <span class="o">.</span><span class="n">onChanged</span> <span class="p">{</span> <span class="n">value</span> <span class="k">in</span>
            <span class="n">decelerationTask</span><span class="p">?</span><span class="o">.</span><span class="nf">cancel</span><span class="p">()</span>

            <span class="k">guard</span> <span class="n">dragStart</span> <span class="o">!=</span> <span class="n">value</span><span class="o">.</span><span class="n">startLocation</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
            <span class="n">dragStart</span> <span class="o">=</span> <span class="n">value</span><span class="o">.</span><span class="n">startLocation</span>
            <span class="n">dragAxis</span> <span class="o">=</span> <span class="nf">abs</span><span class="p">(</span><span class="n">value</span><span class="o">.</span><span class="n">translation</span><span class="o">.</span><span class="n">width</span><span class="p">)</span> <span class="o">&gt;</span> <span class="nf">abs</span><span class="p">(</span><span class="n">value</span><span class="o">.</span><span class="n">translation</span><span class="o">.</span><span class="n">height</span><span class="p">)</span> <span class="p">?</span> <span class="o">.</span><span class="nv">horizontal</span> <span class="p">:</span> <span class="o">.</span><span class="n">vertical</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">updating</span><span class="p">(</span><span class="err">$</span><span class="n">dragOffset</span><span class="p">)</span> <span class="p">{</span> <span class="n">value</span><span class="p">,</span> <span class="n">state</span><span class="p">,</span> <span class="n">_</span> <span class="k">in</span>
            <span class="k">guard</span> <span class="n">dragAxis</span> <span class="o">==</span> <span class="o">.</span><span class="n">horizontal</span> <span class="k">else</span> <span class="p">{</span>
                <span class="n">state</span> <span class="o">=</span> <span class="mi">0</span>
                <span class="k">return</span>
            <span class="p">}</span>
            <span class="n">state</span> <span class="o">=</span> <span class="n">value</span><span class="o">.</span><span class="n">translation</span><span class="o">.</span><span class="n">width</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="n">onEnded</span> <span class="p">{</span> <span class="n">value</span> <span class="k">in</span>
            <span class="k">guard</span> <span class="n">dragAxis</span> <span class="o">==</span> <span class="o">.</span><span class="n">horizontal</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

            <span class="k">var</span> <span class="nv">transaction</span> <span class="o">=</span> <span class="kt">Transaction</span><span class="p">()</span>
            <span class="n">transaction</span><span class="o">.</span><span class="n">disablesAnimations</span> <span class="o">=</span> <span class="kc">true</span>
            <span class="nf">withTransaction</span><span class="p">(</span><span class="n">transaction</span><span class="p">)</span> <span class="p">{</span>
                <span class="n">scrollOffset</span> <span class="o">=</span> <span class="nf">clampedScrollOffset</span><span class="p">(</span><span class="n">scrollOffset</span> <span class="o">+</span> <span class="n">value</span><span class="o">.</span><span class="n">translation</span><span class="o">.</span><span class="n">width</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="nf">decelerate</span><span class="p">(</span><span class="nv">initialVelocity</span><span class="p">:</span> <span class="n">value</span><span class="o">.</span><span class="n">velocity</span><span class="o">.</span><span class="n">width</span><span class="p">)</span>
        <span class="p">}</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">func</span> <span class="nf">decelerate</span><span class="p">(</span><span class="nv">initialVelocity</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">decelerationTask</span><span class="p">?</span><span class="o">.</span><span class="nf">cancel</span><span class="p">()</span>
    <span class="k">guard</span> <span class="nf">abs</span><span class="p">(</span><span class="n">initialVelocity</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">50</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

    <span class="n">decelerationTask</span> <span class="o">=</span> <span class="kt">Task</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">velocity</span> <span class="o">=</span> <span class="n">initialVelocity</span>
        <span class="k">var</span> <span class="nv">lastTick</span> <span class="o">=</span> <span class="kt">ContinuousClock</span><span class="o">.</span><span class="n">now</span>

        <span class="k">while</span> <span class="nf">abs</span><span class="p">(</span><span class="n">velocity</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">12</span> <span class="p">{</span>
            <span class="nf">guard</span> <span class="p">(</span><span class="k">try</span><span class="p">?</span> <span class="k">await</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">sleep</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="o">.</span><span class="nf">milliseconds</span><span class="p">(</span><span class="mi">16</span><span class="p">)))</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

            <span class="k">let</span> <span class="nv">now</span> <span class="o">=</span> <span class="kt">ContinuousClock</span><span class="o">.</span><span class="n">now</span>
            <span class="k">let</span> <span class="nv">dt</span> <span class="o">=</span> <span class="nf">min</span><span class="p">(</span><span class="n">lastTick</span><span class="o">.</span><span class="nf">duration</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">now</span><span class="p">)</span> <span class="o">/</span> <span class="o">.</span><span class="nf">seconds</span><span class="p">(</span><span class="mi">1</span><span class="p">),</span> <span class="mf">0.05</span><span class="p">)</span>
            <span class="n">lastTick</span> <span class="o">=</span> <span class="n">now</span>

            <span class="k">let</span> <span class="nv">unclamped</span> <span class="o">=</span> <span class="n">scrollOffset</span> <span class="o">+</span> <span class="n">velocity</span> <span class="o">*</span> <span class="n">dt</span>
            <span class="n">scrollOffset</span> <span class="o">=</span> <span class="nf">clampedScrollOffset</span><span class="p">(</span><span class="n">unclamped</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">scrollOffset</span> <span class="o">!=</span> <span class="n">unclamped</span> <span class="p">{</span> <span class="k">break</span> <span class="p">}</span>

            <span class="n">velocity</span> <span class="o">*=</span> <span class="nf">pow</span><span class="p">(</span><span class="mf">0.998</span><span class="p">,</span> <span class="n">dt</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three details worth stealing:</p>

<ul>
  <li><strong>Decay per-millisecond, not per-frame.</strong> <code class="language-plaintext highlighter-rouge">velocity *= pow(0.998, dt * 1000)</code> gives
identical physics whether the loop ticks at 60Hz or drops frames. A flat per-frame
constant does not.</li>
  <li><strong>Clamp <code class="language-plaintext highlighter-rouge">dt</code>.</strong> <code class="language-plaintext highlighter-rouge">min(dt, 0.05)</code> keeps a resuming app from teleporting the strip to the
far edge on its first tick.</li>
  <li><strong>Latch the axis by <code class="language-plaintext highlighter-rouge">startLocation</code>, not by a reset in <code class="language-plaintext highlighter-rouge">onEnded</code>.</strong> Cancelled gestures
never call <code class="language-plaintext highlighter-rouge">onEnded</code>, so reset bookkeeping there goes stale and deadens the <em>next</em> pan.
Keying the latch to the start location means every gesture re-latches and there’s
nothing to clean up.</li>
</ul>

<h2 id="results">Results</h2>

<p>Every case in the matrix, exercised with crown-only input before any touch:</p>

<table>
  <thead>
    <tr>
      <th>Case</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cold launch</td>
      <td>Dead vertically; crown flings the hourly strip</td>
      <td>Scrolls the page</td>
    </tr>
    <tr>
      <td>Successful refresh</td>
      <td>Dead</td>
      <td>Works</td>
    </tr>
    <tr>
      <td>Failed refresh → alert → dismiss</td>
      <td>Dead</td>
      <td>Works</td>
    </tr>
    <tr>
      <td>Crown right after swiping the strip</td>
      <td>Crown drives the strip</td>
      <td>Scrolls the page</td>
    </tr>
    <tr>
      <td>Crown inside pushed detail screens</td>
      <td>Dead</td>
      <td>Works</td>
    </tr>
    <tr>
      <td>Crown on root after Back</td>
      <td>Dead</td>
      <td>Works</td>
    </tr>
  </tbody>
</table>

<p>One accepted trade-off, found only by hostile runtime QA rather than code review: a
vertical page flick that <em>starts</em> on the strip is occasionally swallowed, because on
watchOS a descendant <code class="language-plaintext highlighter-rouge">DragGesture</code> starves the outer scroll view’s pan.
<code class="language-plaintext highlighter-rouge">.simultaneousGesture</code>, plain <code class="language-plaintext highlighter-rouge">.gesture</code>, and a larger <code class="language-plaintext highlighter-rouge">minimumDistance</code> all behave
identically. We shipped it anyway — the failure is visible and self-healing (flick again
from anywhere else), which is strictly better than a dead crown that gives no feedback
at all.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>N workarounds for one symptom means the workarounds are the bug.</strong> Five patches that
each half-fixed the same complaint were five pieces of evidence for a shared cause
nobody had named yet.</li>
  <li><strong>Reproduce the mechanism, not the symptom.</strong> “Crown doesn’t scroll” was unfixable for
months. “Crown scrolls the wrong view” was fixable in a day.</li>
  <li><strong>Test the entry state.</strong> Any QA step that touches the screen first destroys the
launch-state bug you’re hunting.</li>
  <li><strong>A workaround can become load-bearing in your mental model.</strong> The focus machinery was
assumed to be part of the fix, so every experiment kept it and iterated around it.</li>
  <li><strong>Nested scrollables compete for crown ownership, and <code class="language-plaintext highlighter-rouge">.focusable(false)</code> won’t save
you.</strong> If the crown feels haunted, count your scroll views. And if the platform would
route it correctly on its own, taking focus manually makes things worse, not safer.</li>
  <li><strong>Adversarial review earns its keep on deletions.</strong> Four review passes caught a
refresh-starvation regression, a snap-back bug, a stale gesture latch, and a lost
VoiceOver scroll action — none of which the original QA matrix covered.</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “it’s been a while since we added any blog posts, see recent work in the ~/Code/helloweather projects, dispatch opus agents to search for interesting stuff that we’ve done since the last blog post, perhaps one or more agents per repo, then review and consider and come up with a proposed list of blog posts we might consider.”</p>

<p><strong>Prompt 2:</strong> “draft posts for [the approved shortlist] – create one pr for the repo main / skills update we just did, then one pr per post for the approved list”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and plan docs, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="swiftui" /><category term="watchos" /><category term="ios" /><category term="debugging" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">An Agent Fan-Out Pipeline with a Hard Isolation Contract</title><link href="https://trevorturk.github.io/agent-fanout-isolation-contract/" rel="alternate" type="text/html" title="An Agent Fan-Out Pipeline with a Hard Isolation Contract" /><published>2026-07-29T15:00:00+00:00</published><updated>2026-07-29T15:00:00+00:00</updated><id>https://trevorturk.github.io/agent-fanout-isolation-contract</id><content type="html" xml:base="https://trevorturk.github.io/agent-fanout-isolation-contract/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Fanning out parallel coding agents is easy. Getting a mergeable result out the other end is not.</p>

<p>Run twenty agents at once on the same repo and you get the same four failures every time:</p>

<ol>
  <li><strong>Merge conflicts.</strong> Every agent needs its work registered in some shared file — an enum, a switch, a router table — and they all edit that file simultaneously.</li>
  <li><strong>Incoherent output.</strong> Twenty agents given twenty vague prompts produce twenty different interpretations of “good.”</li>
  <li><strong>Silent partial work.</strong> Agents die mid-write. API errors, spend limits, timeouts. You get a half-written folder that looks superficially complete.</li>
  <li><strong>Review collapse.</strong> Even if each unit is fine, nobody can meaningfully review 400 files of fast-written code. The reviewer becomes the bottleneck, and then the reviewer becomes a rubber stamp.</li>
</ol>

<p>The instinct is to fix this with better judgment — a smarter reviewing agent, a stricter prompt, more careful reading. That doesn’t scale, because judgment is exactly the resource you ran out of.</p>

<p>What does scale is structure. Below are two runs from <a href="https://helloweather.com">Hello Weather</a> that used the same five-phase shape: an iOS visual-style catalog that went from 41 to roughly 168 entries in about a month, and an Android translation pass that produced 191 strings across 27 locales.</p>

<h2 id="the-pipeline">The Pipeline</h2>

<h3 id="phase-1-parallel-research-fixed-brief-schema">Phase 1: Parallel research, fixed brief schema</h3>

<p>One research agent per aesthetic cluster — graphic-design movements, retro computing and games, transit and wayfinding signage, print and paper craft, instruments and broadcast hardware. Each agent uses web search rather than memory alone, and each returns briefs in an identical shape:</p>

<blockquote>
  <ul>
    <li><strong>Name</strong> + one-line identity (the elevator pitch)</li>
    <li><strong>Grounding</strong>: the specific real works/rules/hardware, era-accurate details with sources</li>
    <li><strong>Palette</strong>: 4-8 hex values; flag interpretive hexes honestly when no official spec exists</li>
    <li><strong>Typography</strong>: system-font approximations</li>
    <li><strong>Section mapping</strong>: hero / hourly / daily / stats, concrete and clever</li>
    <li><strong>Signature element to nail</strong>: the one thing that makes it instantly recognizable</li>
    <li><strong>Feasibility</strong> and <strong>wow factor</strong></li>
    <li><strong>Distinctness check</strong> vs the existing catalog</li>
  </ul>
</blockquote>

<p>The schema is doing two jobs. It makes briefs comparable to each other, so a human can rank thirty of them in one sitting. And it makes briefs <em>executable</em> — a downstream implementation agent gets the palette, the typography, and the section mapping without having to invent any of it.</p>

<p>Then a quality bar, stated as one sentence:</p>

<blockquote>
  <p><strong>The quality bar: the DATA wears the aesthetic.</strong> The best ideas make the forecast become the artwork, not decorate it. Reject briefs where the style is only chrome around a generic chart.</p>
</blockquote>

<p>That single line kills more bad output than any amount of code review. It’s a filter applied at the idea stage, where rejecting something costs nothing.</p>

<p>There is also an explicit constraint in the brief format worth stealing verbatim for any project that draws on cultural reference:</p>

<blockquote>
  <p><strong>IP cautions</strong>: evoke grammar, never copy sprites/logos/mascots/likenesses.</p>
</blockquote>

<p>A visual grammar — a palette, a grid, a typographic register, a way of drawing a dial — is fair game. The specific artwork is not. Making that a required field in the brief means every idea gets checked against it before anyone writes a line of code, rather than after.</p>

<h3 id="phase-2-a-human-checkpoint-before-any-code">Phase 2: A human checkpoint before any code</h3>

<p>Research agents produce one ranked list. Then everything stops.</p>

<blockquote>
  <p>Synthesize into one ranked list and stop for discussion. No task lists, no worktrees, no implementation until the set is picked.</p>
</blockquote>

<p>This is the cheapest phase in the pipeline and the highest leverage. Ideas that get cut here cost minutes. Ideas that get cut after implementation cost an agent-hour each and a pile of merged code that has to come back out. Light sketches that survive the cut get sent back for a second research round to become full briefs before implementation.</p>

<h3 id="phase-3-wire-centrally-then-fan-out">Phase 3: Wire centrally, then fan out</h3>

<p>This is the phase that eliminates merge conflicts, and it is the one most easily skipped.</p>

<p>Before any implementation agent launches, the orchestrator adds <em>every</em> new entry to <em>every</em> shared file: the enum and its display-name switch, the view dispatch, the styling extensions, and — the trap that broke the first run — the two exhaustive switches in the widget and watch targets, which need an explicit arm per case or the build fails.</p>

<p>For dozens of entries at once, hand-editing six files is a mistake. A Python codemod with per-insertion assertions does it instead:</p>

<blockquote>
  <p>For large packs, a Python codemod with per-insertion assertions (fail loudly on a missing anchor, print per-file counts) beats dozens of hand edits.</p>
</blockquote>

<p>The assertions matter more than the automation. A codemod that silently no-ops on a missing anchor is worse than a hand edit, because it produces a plausible-looking diff with a hole in it. Fail on the missing anchor, print a per-file insertion count, and compare that count to what you expected.</p>

<p>Once central wiring lands, every shared file is done. Implementation agents are told, as a hard rule, that they may only create files inside their own folder. Twenty agents, zero overlapping writes, zero conflicts — not because the agents coordinated, but because there was nothing left to coordinate over.</p>

<h3 id="phase-4-one-agent-per-unit-brief-pasted-inline">Phase 4: One agent per unit, brief pasted inline</h3>

<p>The fan-out prompt is a template, and its most important property is that it is <em>repetitive</em>:</p>

<ul>
  <li>Absolute paths in every read and write (worktrees make relative paths a coin flip)</li>
  <li>Read a named reference implementation first, plus the two plan-doc sections describing available data and cross-cutting rules</li>
  <li>Create exactly one folder, with a prescribed file layout and a required type-name prefix on every declaration</li>
  <li>The hard rules <strong>restated verbatim in every prompt</strong>: no comments, no force unwraps or raw indexing, guard zero denominators, no bundled assets, deterministic index-hash randomness only — nothing time- or random-seeded at render time</li>
  <li>The full brief pasted inline</li>
  <li>“Do not build; report summary + files + line counts”</li>
</ul>

<p>Restating the rules in full for every single agent feels redundant when you’re writing the dispatcher. It isn’t. There is no shared memory between parallel agents; a rule stated once in a document that an agent may or may not read is a rule that holds maybe 80% of the time. Eighty percent across 67 units is thirteen violations.</p>

<p>Note the last line: agents do not build. One build at the end, by the orchestrator, is far cheaper than twenty agents each spinning up a compiler and each trying to fix errors in shared state.</p>

<p>Concurrency has a practical ceiling — around 20 agents in flight, with the rest queued and dispatched as completions free up slots.</p>

<h3 id="phase-5-verify-then-repair-as-a-real-phase">Phase 5: Verify-then-repair, as a real phase</h3>

<p>The most valuable single lesson from the whole exercise:</p>

<blockquote>
  <p><strong>Verify-then-repair</strong>: agents can die mid-write (API errors, spend limits). Survey folders: entry view present + <code class="language-plaintext highlighter-rouge">swiftc -parse</code> clean → keep; clearly partial → delete the folder and relaunch fresh. <strong>Never assume a completion.</strong></p>
</blockquote>

<p>Treat the fan-out as <em>unreliable by construction</em>. After every batch, enumerate what should exist, enumerate what does exist, diff the two, and relaunch the gaps from scratch. Not repair — relaunch. A half-written unit is cheaper to delete and redo than to diagnose.</p>

<p>Then mechanical audit greps across all new folders, looking for the rules that were supposed to hold: comment lines, force-unwraps and force-casts, non-verbatim string constructors, raw index access. Every one of those is a regex, not a judgment call.</p>

<p>Only then: one build, fix only clearly-diagnosed errors, one commit, human QA pause, then push.</p>

<h2 id="the-isolation-contract">The Isolation Contract</h2>

<p>Everything above makes the fan-out <em>produce</em> a lot of code. The isolation contract is what makes it <em>safe to merge</em> a lot of fast-written code.</p>

<p>The catalog is debug-only. It is never shipped to users; there is no user-facing picker, and the whole thing sits behind an internal debug gate. The default path renders production views and is untouched. That’s the starting point, but on its own it isn’t enough, because shared helper code is still a coupling channel — and coupling is what turns 81,000 lines of throwaway-quality code into a permanent tax on the production codebase.</p>

<p>So the contract says: <strong>shared style components are copies, never references.</strong></p>

<blockquote>
  <p>Styles work makes minimal-to-zero production changes, so production features can launch and evolve without worrying about styles. Everything under the shared styles directory is <strong>copies</strong> — fork in-flux production views as prefixed types (mechanical copy first, tokenize second); never reference or modify them from shared style code.</p>
</blockquote>

<p>Two bounded exceptions, both deliberate: a style’s own scroll view may <em>call</em> stable production leaf views read-only, and the alerts view is always reused by reference, because the safety-critical surface must have exactly one implementation.</p>

<p>Copies are usually the wrong instinct. Here they’re the entire point. Production charts were mid-rewrite during all of this. If the catalog referenced them, every production change would have had to be validated against 168 downstream consumers of unknown quality. Because the catalog holds copies, the production rewrite shipped without ever considering them.</p>

<p>The rest of the policy follows from that:</p>

<ul>
  <li><strong>Zero-change invariant</strong>: no catalog work may alter default rendering.</li>
  <li><strong>Frozen as-built</strong>: when features land on the default path, catalog entries are <em>not</em> updated. Divergence is expected and accepted, and paid down only if an entry is ever promoted.</li>
  <li><strong>Accessibility debt is deliberate</strong>, not oversight — it’s on the promotion checklist, not the build checklist.</li>
  <li><strong>Don’t refactor across entries.</strong> Persisted raw values stay stable; display names can change.</li>
</ul>

<p>And the partition is verified by a test rather than by discipline. Every entry must belong to exactly one bucket — production, near-shipping, kept, pending-deletion:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span><span class="p">(</span><span class="s">"Every style belongs to a group"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">groupsCoverAllStyles</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">all</span> <span class="o">=</span> <span class="kt">Set</span><span class="p">(</span><span class="kt">SettingsManager</span><span class="o">.</span><span class="kt">ForecastStyle</span><span class="o">.</span><span class="n">allCases</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">union</span> <span class="o">=</span> <span class="kt">SettingsManager</span><span class="o">.</span><span class="kt">ForecastStyle</span><span class="o">.</span><span class="n">styleGroupSets</span>
        <span class="o">.</span><span class="nf">reduce</span><span class="p">(</span><span class="nv">into</span><span class="p">:</span> <span class="kt">Set</span><span class="o">&lt;</span><span class="kt">SettingsManager</span><span class="o">.</span><span class="kt">ForecastStyle</span><span class="o">&gt;</span><span class="p">())</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">formUnion</span><span class="p">(</span><span class="nv">$1</span><span class="o">.</span><span class="mi">1</span><span class="p">)</span> <span class="p">}</span>

    <span class="cp">#expect(all.subtracting(union).isEmpty)</span>
<span class="p">}</span>

<span class="kd">@Test</span><span class="p">(</span><span class="s">"Production is exactly the default style"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">productionIsTheDefault</span><span class="p">()</span> <span class="p">{</span>
    <span class="cp">#expect(SettingsManager.ForecastStyle.productionStyles == [.helloWeather])</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Coverage, disjointness, exact count, and “production contains exactly one thing.” Adding an entry without classifying it fails the test. The isolation boundary is an assertion, not a convention.</p>

<h3 id="two-performance-findings-worth-keeping">Two performance findings worth keeping</h3>

<p>Fan-out produces work nobody has profiled, and two problems showed up repeatedly:</p>

<ul>
  <li><strong>Hundreds of concurrently animated views is a meltdown.</strong> The fix is to sequence groups so only one animates at a time, and to add <code class="language-plaintext highlighter-rouge">drawingGroup()</code> per row to collapse compositing layers.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">rotation3DEffect</code> at exactly ±90° with perspective</strong> produces a degenerate transform and logs “ignoring singular matrix” every frame. Clamp to ±89.9°.</li>
</ul>

<p>Also: hold any entrance choreography until loading clears, so animation never fights launch and refresh work.</p>

<h2 id="case-study-27-locales">Case Study: 27 Locales</h2>

<p>The same shape, a completely different domain, and a much sharper verifier.</p>

<p>The task: translate 191 Android strings into 27 locales. One agent per locale, 27 in parallel.</p>

<p><strong>The brief-schema equivalent</strong> was an anchor rather than a template. Each agent was pointed at the sibling iOS app’s professionally-reviewed translation file and told to match its terminology for the same locale. That single anchor removes the largest source of variance in parallel translation — twenty-seven agents independently deciding how to render “feels like” or “chance of rain.” The terminology decisions were already made, by humans, and paid for.</p>

<p><strong>Central wiring came first</strong>, exactly as in the iOS case: a separate, behaviorally inert commit declaring all 28 locales in the locale config, wiring the per-app language picker, and adding the AppCompat service that persists the user’s choice across OS versions. No translation files in that commit at all. With English-only resources present, it changes nothing — which is the point. The shared configuration file was finished and merged before a single translation agent ran.</p>

<p><strong>The verifier is completely mechanical.</strong> Every locale must pass:</p>

<ul>
  <li>Key-set equality with the source file (no added strings, no dropped strings, no renamed <code class="language-plaintext highlighter-rouge">name</code> attributes)</li>
  <li>Format-specifier preservation — <code class="language-plaintext highlighter-rouge">%1$s</code>, <code class="language-plaintext highlighter-rouge">%d</code>, <code class="language-plaintext highlighter-rouge">%1$.0f</code>, <code class="language-plaintext highlighter-rouge">%%</code> — present, correct, and in the right order</li>
  <li>Escaping rules: backslash-escaped apostrophes and <code class="language-plaintext highlighter-rouge">@</code>, entities or CDATA for ampersands and ellipses</li>
  <li>XML validity</li>
  <li>And then the whole thing has to survive a full resource merge and compile with all 27 locales present</li>
</ul>

<p>Not one of those checks requires reading the translation. That’s the design goal. A parallel pipeline needs a gate that says <em>yes</em> or <em>no</em> without a human in the loop, and “does this Czech sentence read well” is not that gate. Key-set equality is.</p>

<p>The residual risk is real and named rather than pretended away: mechanical checks confirm structural correctness, not fluency. Professional post-editing is planned as a separate pass. The verifier’s job is to guarantee nothing is <em>broken</em>, not to guarantee everything is <em>good</em>.</p>

<p><strong>The platform wrinkle</strong> is the interesting part, and it’s the isolation contract showing up in an unfamiliar costume. Android auto-enables any <code class="language-plaintext highlighter-rouge">values-XX/strings.xml</code> present in the build:</p>

<blockquote>
  <p>Android automatically enables any <code class="language-plaintext highlighter-rouge">values-XX/strings.xml</code> present in the build — unlike iOS, there is no debug-only option. Only create translation files when ready to ship them to all users.</p>
</blockquote>

<p>There is no debug gate available. Merging the branch <em>is</em> the launch. So the branch itself became the isolation boundary: it stays a deliberate draft, complete and verified and unmerged, until someone decides to ship localization. The commit message says so explicitly.</p>

<p>That’s the general lesson. Every fan-out needs a boundary that lets fast-produced output exist without being live. On iOS it was a debug flag plus a copy-not-reference rule. On Android the platform refused to provide one, so the boundary moved up a level to version control. What matters is that the boundary exists and is written down — not which layer it lives at.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Parallel agents don’t need a smarter reviewer, they need a contract.</strong> Every fix here is structural. None of them is “review more carefully.”</li>
  <li><strong>A brief schema is a coordination protocol.</strong> Fixed fields make outputs comparable for humans and executable for downstream agents. Freeform research briefs give you neither.</li>
  <li><strong>Wire centrally, then fan out.</strong> Finish every shared file <em>before</em> launching parallel workers, and the entire class of merge conflicts disappears. This is the single highest-value phase.</li>
  <li><strong>Codemods need per-insertion assertions.</strong> A codemod that silently skips a missing anchor is worse than a hand edit, because the resulting diff looks fine.</li>
  <li><strong>Restate hard rules verbatim in every prompt.</strong> Parallel agents share no memory. A rule stated once holds most of the time, and “most” times sixty-seven is a lot of violations.</li>
  <li><strong>Prefer a mechanical verifier to a judgment-based one.</strong> Key-set equality, format-specifier preservation, XML validity, a clean parse, audit greps. If the gate needs taste, it won’t run at scale.</li>
  <li><strong>Verify-then-repair is a phase, not an afterthought.</strong> Agents die mid-write. Enumerate expected versus actual, and relaunch the gaps from scratch rather than debugging partial output.</li>
  <li><strong>Copies beat references when quality is uneven.</strong> Coupling fast-written code to production code makes every future production change a 168-way compatibility problem.</li>
  <li><strong>Make the boundary a test.</strong> A partition that’s enforced by a coverage-and-disjointness test can’t quietly rot; a partition enforced by convention will.</li>
  <li><strong>Name the residual risk instead of hiding it.</strong> Deliberate accessibility debt and pending professional translation review are both written down. Undocumented debt is the kind that surprises you.</li>
  <li><strong>Evoke the grammar, never copy the artwork.</strong> Make it a required field in the brief, so it’s checked before implementation rather than after.</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “it’s been a while since we added any blog posts, see recent work in the ~/Code/helloweather projects, dispatch opus agents to search for interesting stuff that we’ve done since the last blog post, perhaps one or more agents per repo, then review and consider and come up with a proposed list of blog posts we might consider.”</p>

<p><strong>Prompt 2:</strong> “draft posts for [the approved shortlist] – create one pr for the repo main / skills update we just did, then one pr per post for the approved list”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and skill files, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="ai-agents" /><category term="parallelism" /><category term="swiftui" /><category term="ios" /><category term="android" /><category term="workflow" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Measuring Strings Before You Translate Them: Fixing Truncation in 22 Languages Without an App Update</title><link href="https://trevorturk.github.io/rendered-width-validation/" rel="alternate" type="text/html" title="Measuring Strings Before You Translate Them: Fixing Truncation in 22 Languages Without an App Update" /><published>2026-07-29T14:30:00+00:00</published><updated>2026-07-29T14:30:00+00:00</updated><id>https://trevorturk.github.io/rendered-width-validation</id><content type="html" xml:base="https://trevorturk.github.io/rendered-width-validation/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Localized UI truncation is discovered by customers, not by developers. The loop looks like this:</p>

<ol>
  <li>Ship a screen that fits perfectly in English</li>
  <li>Translate it into 25 more languages</li>
  <li>Wait for a support ticket that says “the Spanish text is cut off”</li>
  <li>Fix that one string, ship an app update, repeat</li>
</ol>

<p>The reason this loop is so slow is that nothing in the toolchain knows how wide a string will be. A localization catalog stores text. A SwiftUI layout computes widths at runtime, on device, in a specific font at a specific Dynamic Type size. The two facts never meet until a human looks at a screenshot.</p>

<p>Worse, the truncation is systemic rather than incidental. If <code class="language-plaintext highlighter-rouge">Air Quality</code> overflows a stat card title in Vietnamese, it overflows in the widget too, because it’s the same catalog key in the same slot class. And if the string comes from your <em>server</em> - a level name, an advisory phrase - then even a one-word fix requires an app release that has nothing to do with the app.</p>

<p>At <a href="https://helloweather.com">Hello Weather</a> we hit all of it at once: customer QA reported cut-off text in the small stat cards, in Spanish. Rather than fix the Spanish strings, we went looking for a way to know, ahead of time, every string in every language that would not fit.</p>

<h2 id="the-solution">The Solution</h2>

<p>The pattern has three parts, and none of them require running the app:</p>

<ol>
  <li><strong>A committed JSON registry of layout constraints</strong> - which catalog keys render in which width-constrained slots, and what the budget is for each</li>
  <li><strong>A dependency-free validator script</strong> that compiles and runs standalone, reads the registry plus the localization catalog, and measures</li>
  <li><strong>A committed markdown report</strong> that is simultaneously the work-list, the diff, and the regression baseline</li>
</ol>

<p>Plus a lifecycle: the validator starts in <strong>audit mode</strong> (report findings, exit 0) while there’s a backlog, then flips to <strong>gate mode</strong> (non-zero exit) once the backlog is cleared.</p>

<p>We built this twice. The first generation counted characters. The second generation measured actual rendered widths. Both were useful, and the difference between them is the interesting part.</p>

<h2 id="generation-1-a-character-budget-registry">Generation 1: A Character-Budget Registry</h2>

<p>The registry is a plain JSON file, committed to the repo:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"_readme"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Width budgets for catalog keys rendering in width-constrained slots (stat cards, chart legends, widget rows, complication labels). budget = max Character count for every language value except cjkExempt. scales = per-language values within one group must stay pairwise distinct."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"cjkExempt"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"ja"</span><span class="p">,</span><span class="w"> </span><span class="s2">"ko"</span><span class="p">,</span><span class="w"> </span><span class="s2">"zh-Hans"</span><span class="p">,</span><span class="w"> </span><span class="s2">"zh-Hant"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"keys"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Air Quality"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"slots"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"statTitle"</span><span class="p">],</span><span class="w">         </span><span class="nl">"budget"</span><span class="p">:</span><span class="w"> </span><span class="mi">17</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"Cloudy"</span><span class="p">:</span><span class="w">      </span><span class="p">{</span><span class="w"> </span><span class="nl">"slots"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chartLegend"</span><span class="p">],</span><span class="w">       </span><span class="nl">"budget"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="w">  </span><span class="p">},</span><span class="w">
    </span><span class="nl">"AQI"</span><span class="p">:</span><span class="w">         </span><span class="p">{</span><span class="w"> </span><span class="nl">"slots"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"complicationLabel"</span><span class="p">],</span><span class="w"> </span><span class="nl">"budget"</span><span class="p">:</span><span class="w"> </span><span class="mi">6</span><span class="w">  </span><span class="p">},</span><span class="w">
    </span><span class="nl">"Actual"</span><span class="p">:</span><span class="w">      </span><span class="p">{</span><span class="w"> </span><span class="nl">"slots"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"miniTitle"</span><span class="p">],</span><span class="w">         </span><span class="nl">"budget"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="w">  </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"scales"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"uvLegend"</span><span class="p">:</span><span class="w">       </span><span class="p">[</span><span class="s2">"Low"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Mid"</span><span class="p">,</span><span class="w"> </span><span class="s2">"High"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Max"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"pressureLegend"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Low"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Normal"</span><span class="p">,</span><span class="w"> </span><span class="s2">"High"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"visibilityLegend"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Good"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Fair"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Poor"</span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Seventy-four keys across six slot classes. Two things are encoded here that a linter couldn’t infer.</p>

<p><strong>Slot classes.</strong> A key is constrained because of <em>where it renders</em>, and one key can render in several places. Writing the slot down makes the budget reviewable - someone can ask “is 17 characters really the stat title budget?” without reading layout code.</p>

<p><strong>Scale groups.</strong> These are sets of labels that appear together in one chart legend. The rule isn’t about length at all: within a scale, every language’s values must be pairwise distinct. A translator working key-by-key has no way to know that two English words map to the same natural word in their language.</p>

<p>That second rule found two live shipping bugs on the very first run:</p>

<ul>
  <li>The Czech cloud legend rendered the same word for both <code class="language-plaintext highlighter-rouge">Cloudy</code> and <code class="language-plaintext highlighter-rouge">Overcast</code></li>
  <li>The Russian UV legend rendered the same word for both <code class="language-plaintext highlighter-rouge">High</code> and <code class="language-plaintext highlighter-rouge">Max</code></li>
</ul>

<p>Two identically-labeled swatches, different colors, in a chart that shipped. No amount of width measurement would have caught those; no reviewer scanning a translation file key-by-key would have either.</p>

<p>The validator is about 90 lines of Foundation. It parses the registry and the localization catalog as plain JSON - no app dependency, no test target:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">scale</span> <span class="k">in</span> <span class="n">scales</span><span class="o">.</span><span class="n">keys</span><span class="o">.</span><span class="nf">sorted</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">language</span> <span class="k">in</span> <span class="n">checkedLanguages</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">seen</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[:]</span>
        <span class="k">for</span> <span class="n">key</span> <span class="k">in</span> <span class="n">scales</span><span class="p">[</span><span class="n">scale</span><span class="p">]</span> <span class="p">??</span> <span class="p">[]</span> <span class="p">{</span>
            <span class="k">guard</span> <span class="k">let</span> <span class="nv">translated</span> <span class="o">=</span> <span class="nf">value</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">language</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">continue</span> <span class="p">}</span>
            <span class="k">if</span> <span class="k">let</span> <span class="nv">previous</span> <span class="o">=</span> <span class="n">seen</span><span class="p">[</span><span class="n">translated</span><span class="p">]</span> <span class="p">{</span>
                <span class="n">findings</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"FINDING: within-scale duplicate in </span><span class="se">\(</span><span class="n">scale</span><span class="se">)</span><span class="s"> "</span> <span class="o">+</span>
                                <span class="s">"</span><span class="se">\(</span><span class="n">language</span><span class="se">)</span><span class="s">: </span><span class="se">\"\(</span><span class="n">previous</span><span class="se">)\"</span><span class="s"> and </span><span class="se">\"\(</span><span class="n">key</span><span class="se">)\"</span><span class="s"> "</span> <span class="o">+</span>
                                <span class="s">"both </span><span class="se">\"\(</span><span class="n">translated</span><span class="se">)\"</span><span class="s">"</span><span class="p">)</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="n">seen</span><span class="p">[</span><span class="n">translated</span><span class="p">]</span> <span class="o">=</span> <span class="n">key</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A bash wrapper compiles it to a temp directory and runs it, so the whole tool is <code class="language-plaintext highlighter-rouge">./tools/validate-compact-strings</code> with no build system involvement:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail
<span class="nv">script_dir</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="k">${</span><span class="nv">BASH_SOURCE</span><span class="p">[0]</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="si">)</span><span class="s2">"</span>
<span class="nv">tmp_dir</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">mktemp</span> <span class="nt">-d</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TMPDIR</span><span class="k">:-</span><span class="p">/tmp</span><span class="k">}</span><span class="s2">/compact-string-validation.XXXXXX"</span><span class="si">)</span><span class="s2">"</span>
<span class="nb">trap</span> <span class="s1">'rm -rf "$tmp_dir"'</span> EXIT

swiftc <span class="nt">-parse-as-library</span> <span class="s2">"</span><span class="nv">$script_dir</span><span class="s2">/validate-compact-strings.swift"</span> <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$tmp_dir</span><span class="s2">/validator"</span>
<span class="nv">TOOLS_DIR</span><span class="o">=</span><span class="s2">"</span><span class="nv">$script_dir</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$tmp_dir</span><span class="s2">/validator"</span>
</code></pre></div></div>

<p>First run: <strong>211 findings across 26 languages.</strong> That number is not a failure - it’s a work-list, ordered and diffable.</p>

<h2 id="generation-2-measuring-what-actually-renders">Generation 2: Measuring What Actually Renders</h2>

<p>Character counts are a proxy, and a bad one. <code class="language-plaintext highlighter-rouge">Ω</code> and <code class="language-plaintext highlighter-rouge">l</code> are both one character. Cyrillic is wider than Latin at the same count. And a character budget can’t express the difference between a 13pt regular description and an 11pt semibold uppercased title in the same card.</p>

<p>So the second validator measures real rendered widths using AppKit text measurement on the desktop, with macOS SF Pro standing in for iOS SF Pro:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">width</span><span class="p">(</span><span class="n">_</span> <span class="nv">string</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="n">_</span> <span class="nv">size</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="kt">NSFont</span><span class="o">.</span><span class="kt">Weight</span> <span class="o">=</span> <span class="o">.</span><span class="n">regular</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">CGFloat</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">font</span> <span class="o">=</span> <span class="kt">NSFont</span><span class="o">.</span><span class="nf">systemFont</span><span class="p">(</span><span class="nv">ofSize</span><span class="p">:</span> <span class="n">size</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="n">weight</span><span class="p">)</span>
    <span class="k">return</span> <span class="nf">ceil</span><span class="p">(</span><span class="kt">NSAttributedString</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="n">string</span><span class="p">,</span> <span class="nv">attributes</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="nv">font</span><span class="p">:</span> <span class="n">font</span><span class="p">])</span><span class="o">.</span><span class="nf">size</span><span class="p">()</span><span class="o">.</span><span class="n">width</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The budget side is the part worth copying. Instead of a hand-picked number, it re-derives the layout from the actual grid formula the SwiftUI view uses:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="k">let</span> <span class="nv">deviceWidth</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">375</span>        <span class="c1">// smallest supported width</span>
<span class="kd">static</span> <span class="k">let</span> <span class="nv">gridOuterPadding</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">32</span>
<span class="kd">static</span> <span class="k">let</span> <span class="nv">gridSpacing</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">10</span>
<span class="kd">static</span> <span class="k">let</span> <span class="nv">gridMinimumColumn</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">165</span>  <span class="c1">// adaptive grid minimum</span>
<span class="kd">static</span> <span class="k">let</span> <span class="nv">cardPadding</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">32</span>
<span class="kd">static</span> <span class="k">let</span> <span class="nv">iconAllowance</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">36</span>
<span class="kd">static</span> <span class="k">let</span> <span class="nv">headroom</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mf">0.95</span>          <span class="c1">// proxy-font margin</span>

<span class="kd">static</span> <span class="k">var</span> <span class="nv">descriptionBudget</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">available</span> <span class="o">=</span> <span class="n">deviceWidth</span> <span class="o">-</span> <span class="n">gridOuterPadding</span>
    <span class="k">let</span> <span class="nv">columns</span> <span class="o">=</span> <span class="nf">floor</span><span class="p">((</span><span class="n">available</span> <span class="o">+</span> <span class="n">gridSpacing</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">gridMinimumColumn</span> <span class="o">+</span> <span class="n">gridSpacing</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">column</span> <span class="o">=</span> <span class="p">(</span><span class="n">available</span> <span class="o">-</span> <span class="p">(</span><span class="n">columns</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="n">gridSpacing</span><span class="p">)</span> <span class="o">/</span> <span class="n">columns</span>
    <span class="k">return</span> <span class="n">column</span> <span class="o">-</span> <span class="n">cardPadding</span>
<span class="p">}</span>

<span class="kd">static</span> <span class="k">var</span> <span class="nv">titleBudget</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="p">{</span> <span class="n">descriptionBudget</span> <span class="o">-</span> <span class="n">iconAllowance</span> <span class="p">}</span>
<span class="kd">static</span> <span class="k">var</span> <span class="nv">passBar</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="p">{</span> <span class="n">descriptionBudget</span> <span class="o">*</span> <span class="n">headroom</span> <span class="p">}</span>
</code></pre></div></div>

<p>That yields 134.5pt for descriptions and 98.5pt for titles and subtitles. Because the formula mirrors the view, a layout change to spacing or column minimum is a one-line change in the tool - not a re-guess of every budget.</p>

<p>The 5% headroom matters too. Desktop SF Pro is a proxy, not the real thing, so results land in three buckets rather than two: <code class="language-plaintext highlighter-rouge">OK</code>, <code class="language-plaintext highlighter-rouge">MARGIN</code> (inside the 5% band, needs device verification), and <code class="language-plaintext highlighter-rouge">OVER</code>.</p>

<h3 id="worst-case-format-arguments">Worst-Case Format Arguments</h3>

<p>Here’s the part that separates a real measurement tool from a toy: <strong>most constrained strings are format templates, not literals.</strong> <code class="language-plaintext highlighter-rouge">Sunrise at %@.</code> has no width until you fill it in. Measuring the template is meaningless; measuring it with a convenient argument is worse, because it silently passes.</p>

<p>So the tool synthesizes the widest legal argument for each placeholder:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Widest clock string for this locale (both 12h and 24h are measured)</span>
<span class="kd">static</span> <span class="kd">func</span> <span class="nf">worstTime12</span><span class="p">(</span><span class="n">_</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="nf">formattedDate</span><span class="p">(</span><span class="n">language</span><span class="p">,</span> <span class="nv">pattern</span><span class="p">:</span> <span class="s">"h:mma"</span><span class="p">,</span> <span class="nv">hour</span><span class="p">:</span> <span class="mi">12</span><span class="p">,</span> <span class="nv">minute</span><span class="p">:</span> <span class="mi">59</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">lowercased</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="nf">locale</span><span class="p">(</span><span class="n">language</span><span class="p">))</span>
<span class="p">}</span>

<span class="c1">// Widest noun that can fill a precip template</span>
<span class="kd">static</span> <span class="kd">func</span> <span class="nf">worstPrecipNoun</span><span class="p">(</span><span class="n">_</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="p">[</span><span class="s">"Rain"</span><span class="p">,</span> <span class="s">"Snow"</span><span class="p">,</span> <span class="s">"Sleet"</span><span class="p">,</span> <span class="s">"Hail"</span><span class="p">,</span> <span class="s">"Precip"</span><span class="p">]</span>
        <span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nf">catalogValue</span><span class="p">(</span><span class="nv">$0</span><span class="p">,</span> <span class="n">language</span><span class="p">)</span> <span class="p">}</span>
        <span class="o">.</span><span class="nf">max</span><span class="p">(</span><span class="nv">by</span><span class="p">:</span> <span class="p">{</span> <span class="nf">width</span><span class="p">(</span><span class="nv">$0</span><span class="p">,</span> <span class="mi">13</span><span class="p">)</span> <span class="o">&lt;</span> <span class="nf">width</span><span class="p">(</span><span class="nv">$1</span><span class="p">,</span> <span class="mi">13</span><span class="p">)</span> <span class="p">})</span> <span class="p">??</span> <span class="s">"Rain"</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The catalog reader does the same thing for plurals - when an entry has plural variations rather than a single string unit, it returns the <em>widest</em> variant, not the <code class="language-plaintext highlighter-rouge">other</code> case:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="k">let</span> <span class="nv">plural</span> <span class="o">=</span> <span class="p">(</span><span class="n">localization</span><span class="p">[</span><span class="s">"variations"</span><span class="p">]</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])?[</span><span class="s">"plural"</span><span class="p">]</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">]</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">values</span> <span class="o">=</span> <span class="n">plural</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span>
        <span class="p">((</span><span class="nv">$0</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])?[</span><span class="s">"stringUnit"</span><span class="p">]</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])?[</span><span class="s">"value"</span><span class="p">]</span> <span class="k">as?</span> <span class="kt">String</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">values</span><span class="o">.</span><span class="nf">max</span><span class="p">(</span><span class="nv">by</span><span class="p">:</span> <span class="p">{</span> <span class="nf">width</span><span class="p">(</span><span class="nv">$0</span><span class="p">,</span> <span class="mi">13</span><span class="p">)</span> <span class="o">&lt;</span> <span class="nf">width</span><span class="p">(</span><span class="nv">$1</span><span class="p">,</span> <span class="mi">13</span><span class="p">)</span> <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The last category of argument is the important one. Many of the widest strings in the app are not in the app at all. Level names (“Very Unhealthy”), advisory phrases (“Health effects possible.”), wind bearings, and composed pollen phrases are all returned by our API, localized on the server. So the tool reads our server repo’s locale files directly, converting YAML to JSON in the wrapper and passing a directory path in:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>yml <span class="k">in</span> <span class="s2">"</span><span class="nv">$web_dir</span><span class="s2">"</span>/config/locales/<span class="k">*</span>.yml<span class="p">;</span> <span class="k">do
  </span><span class="nv">lang</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$yml</span><span class="s2">"</span> .yml<span class="si">)</span><span class="s2">"</span>
  ruby <span class="nt">-ryaml</span> <span class="nt">-rjson</span> <span class="nt">-e</span> <span class="s1">'puts JSON.generate(YAML.safe_load(File.read(ARGV[0])))'</span> <span class="se">\</span>
    <span class="s2">"</span><span class="nv">$yml</span><span class="s2">"</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$web_json_dir</span><span class="s2">/</span><span class="nv">$lang</span><span class="s2">.json"</span>
<span class="k">done
</span><span class="nv">web_head</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$web_dir</span><span class="s2">"</span> rev-parse <span class="nt">--short</span> HEAD<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>The report records the server checkout’s commit SHA and warns when it differs from that repo’s main branch, so a stale baseline announces itself; if the checkout isn’t present at all, the tool degrades to client-key coverage with a warning instead of failing. Rows built from synthesized rather than real values (temperatures, precip amounts, wind units) are tagged <code class="language-plaintext highlighter-rouge">[estimate]</code>, so a reader knows which findings are inferences.</p>

<h3 id="the-committed-report">The Committed Report</h3>

<p>The tool writes a markdown file that is checked in:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gu">## Summary</span>
<span class="p">
-</span> Rows measured: 1620 (27 languages)
<span class="p">-</span> Over budget at default type size: <span class="gs">**483**</span>
<span class="p">-</span> Inside margin (127.8-134.5pt band): 78
<span class="p">-</span> Over budget at the xxLarge cap: 674

| Card | Slot | Language | Width | Verdict | Source | Rendered |
|---|---|---|---|---|---|---|
| AQI | description | de | 242/134pt | OVER | <span class="sb">`server:aqiLevelPhrase`</span> | Gesundheitliche Auswirkungen moeglich. |
| AQI | description | en | 144/134pt | OVER | <span class="sb">`server:aqiLevelPhrase`</span> | Health effects possible. |
| AQI | subtitle | it | 134/98pt | OVER | <span class="sb">`server:aqiLevelName`</span> | Molto Insalubre |
</code></pre></div></div>

<p>Committing generated output feels wrong until you use it once. It buys three things:</p>

<ul>
  <li><strong>A work-list.</strong> Sorted worst-first per card, it tells the copy pass what to fix and in what order.</li>
  <li><strong>A diff.</strong> Re-run the tool on a branch and <code class="language-plaintext highlighter-rouge">git diff</code> shows exactly which rows moved. That is the review artifact for a translation PR.</li>
  <li><strong>A baseline.</strong> The report is the definition of “no worse than before.”</li>
</ul>

<p>It also surfaced things nobody was looking for: stat card <em>titles</em> truncate today (Vietnamese <code class="language-plaintext highlighter-rouge">Chất lượng không khí</code> at 141pt in a 98pt slot, with eight more languages over on the same key), English itself fails 9 rows, and a client bug where a <code class="language-plaintext highlighter-rouge">.capitalized</code> call was title-casing Spanish level names mid-sentence.</p>

<h2 id="the-server-loop">The Server Loop</h2>

<p>Of the 483 over-budget rows, <strong>140 came from server-owned strings</strong> - and that’s the architectural payoff. Because those phrases live in our API’s locale files rather than in the app bundle, fixing them is a content change that deploys: no App Store review, no version gate, no waiting for users to update. A truncation bug became a copy edit.</p>

<p>The server-side pass shortened <strong>300+ locale values across 22 languages</strong>, under one rule worth stealing:</p>

<blockquote>
  <p><strong>The dual-surface rule:</strong> a shortened value must still read as natural prose on the app’s detail screen and on the web product - not merely fit the card.</p>
</blockquote>

<p>This is what stops “make it fit” from degrading into telegraphese. A phrase like a pressure trend name has to work as a standalone card label <em>and</em> inside a sentence:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># before -&gt; after, es</span>
<span class="na">pressure</span><span class="pi">:</span>
  <span class="na">trend_ext_name</span><span class="pi">:</span>
    <span class="na">falling-quickly</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Cae</span><span class="nv"> </span><span class="s">rápido"</span>   <span class="c1"># was "Bajando rápido"</span>
    <span class="na">falling</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Bajando"</span>
</code></pre></div></div>

<p>Note that <code class="language-plaintext highlighter-rouge">falling-quickly</code> and <code class="language-plaintext highlighter-rouge">falling</code> are adjacent steps in the same scale - so the shortened value still has to stay lexically distinct from its neighbor, which is the generation-1 scale rule showing up again on the server side.</p>

<p>Verification closed the loop: point the client’s width tool at the server branch and re-run. <strong>Server-string findings dropped from 140 to 74.</strong></p>

<p>The remaining 74 are not failures, they’re <em>adjudicated keeps</em> - rows where no natural short form exists, recorded explicitly:</p>

<ul>
  <li>Composed two-item pollen phrases stay over in ~10 languages, because the joined nouns alone approach the budget. That one moves back to the client as a layout change (show the dominant type only).</li>
  <li>Thai wind bearings were deliberately left unabbreviated, because the local convention writes them out and abbreviating damages the prose surface.</li>
  <li>Indonesian air quality names depart from the official band terminology to fit an 18pt subtitle - a documented trade, not an oversight.</li>
</ul>

<p>Writing keeps down, in the same artifact as the findings, is what makes the report safe to gate on later. A row that stays over budget forever is fine as long as somebody decided that on purpose.</p>

<h2 id="results">Results</h2>

<ul>
  <li><strong>74 keys</strong> across 6 slot classes registered with explicit budgets; 14 scale groups</li>
  <li><strong>211 findings</strong> on the first character-budget run, including two live legend bugs in shipping charts</li>
  <li><strong>1,620 rows</strong> measured at real rendered widths across 27 languages; 483 over budget at the default type size</li>
  <li><strong>300+ server locale values</strong> shortened across 22 languages, deployed as content</li>
  <li><strong>Server-string findings: 140 to 74</strong>, every remainder adjudicated and recorded</li>
  <li><strong>Zero app updates</strong> required for the server-owned half of the fix</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li>
    <p><strong>Measure rendered width, not character count.</strong> Characters are a useful first pass - cheap, dependency-free, and the scale-distinctness check works on them - but they can’t distinguish an 11pt semibold uppercased title from a 13pt regular description in the same card. Ship the cheap version first if it unblocks you, then replace it.</p>
  </li>
  <li>
    <p><strong>Derive budgets from the layout formula, not from taste.</strong> Copying the grid arithmetic into the tool means a spacing change is a one-line edit, not a re-guess of 74 numbers. Hard-coded budgets rot the moment somebody touches the view.</p>
  </li>
  <li>
    <p><strong>A format template has no width.</strong> Every placeholder needs a worst-case argument: widest plural variant, widest clock format for the locale, widest enumerated noun, longest real server value. Measuring <code class="language-plaintext highlighter-rouge">%@ starting in %lldm.</code> measures nothing.</p>
  </li>
  <li>
    <p><strong>Standalone beats integrated.</strong> A <code class="language-plaintext highlighter-rouge">swiftc</code> invocation on one file, wrapped in bash, runs in seconds with no project build, no simulator, no test target. That’s what makes it something you actually run on a branch before opening a PR.</p>
  </li>
  <li>
    <p><strong>Commit the report.</strong> Generated output in the repo turns into a work-list, a reviewable diff, and a baseline all at once. The diff on a translation PR is the review.</p>
  </li>
  <li>
    <p><strong>Audit mode first, gate mode later.</strong> A validator that fails on day one with 211 findings gets disabled on day two. Exit 0 while the backlog exists, then flip to a hard gate once it’s cleared - and make the flip its own change, so somebody has to decide.</p>
  </li>
  <li>
    <p><strong>Record the keeps.</strong> “Cannot be fixed without unnatural language” is a legitimate outcome. Writing it into the same report as the findings is what lets you turn the audit into a gate without lying about the remainder.</p>
  </li>
  <li>
    <p><strong>Server-owned display strings are an architecture decision with a UI payoff.</strong> Putting level names and advisory phrases in the API rather than the app bundle looked like a normalization choice. It turned out to mean a whole class of UI-fit bugs is fixable by deploy. Worth weighing the next time you decide where a string should live.</p>
  </li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt 1:</strong> “it’s been a while since we added any blog posts, see recent work in the ~/Code/helloweather projects, dispatch opus agents to search for interesting stuff that we’ve done since the last blog post, perhaps one or more agents per repo, then review and consider and come up with a proposed list of blog posts we might consider.”</p>

<p><strong>Prompt 2:</strong> “draft posts for [the approved shortlist] – create one pr for the repo main / skills update we just did, then one pr per post for the approved list”</p>

<p>Research by one Claude agent per repo mining git history since the previous post; this draft was written by a dedicated agent from that research plus the underlying commits and tools, then reviewed before publishing.</p>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="localization" /><category term="i18n" /><category term="tooling" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Server-Controlled Promo System with Offer Codes</title><link href="https://trevorturk.github.io/server-controlled-promo-system/" rel="alternate" type="text/html" title="Server-Controlled Promo System with Offer Codes" /><published>2026-04-03T14:00:00+00:00</published><updated>2026-04-03T14:00:00+00:00</updated><id>https://trevorturk.github.io/server-controlled-promo-system</id><content type="html" xml:base="https://trevorturk.github.io/server-controlled-promo-system/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Running promotional campaigns for iOS subscriptions is harder than it looks. The naive approach - using StoreKit’s introductory offers - has a critical flaw: <strong>you can’t reliably detect eligibility</strong>.</p>

<p>StoreKit 2 tells you if a user is eligible for an introductory offer, but only for users who have never subscribed <em>on the current device</em>. It can’t see:</p>

<ul>
  <li>Previous subscriptions on other devices</li>
  <li>Family members who shared a subscription</li>
  <li>Users who had a free trial months ago</li>
  <li>TestFlight users who tested subscriptions</li>
</ul>

<p>This creates a terrible user experience: you show someone a “50% off!” banner, they tap it, and the purchase fails or charges full price because they’re secretly ineligible.</p>

<p>We also wanted to launch and end campaigns without shipping app updates. Marketing shouldn’t wait for App Review.</p>

<h2 id="the-solution">The Solution</h2>

<p>We built a three-layer promo system:</p>

<ol>
  <li><strong>Server-controlled activation</strong> - Promo key delivered in API response, toggled via deploy</li>
  <li><strong>Client-side supported promos</strong> - iOS only shows UI for promos it knows how to render</li>
  <li><strong>Offer codes via CLI</strong> - App Store Connect offer management through a skill and script system</li>
</ol>

<p>The key insight: <strong>offer codes have no eligibility restrictions</strong>. Anyone can redeem them, which means no surprise failures.</p>

<h2 id="server-promo-activation">Server: Promo Activation</h2>

<p>Promo configuration lives in a YAML file on the server:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/appstore/pricing_strategy.yml</span>
<span class="na">promo</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">happy10</span>
  <span class="na">startDate</span><span class="pi">:</span> <span class="s2">"</span><span class="s">2026-03-01"</span>
  <span class="na">endDate</span><span class="pi">:</span> <span class="s2">"</span><span class="s">2026-03-31"</span>
</code></pre></div></div>

<p>The API checks the date window and returns the promo key:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/api/promo.rb</span>
<span class="k">class</span> <span class="nc">Api::Promo</span> <span class="o">&lt;</span> <span class="no">Api</span><span class="o">::</span><span class="no">Base</span>
  <span class="no">STRATEGY_PATH</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"config/appstore/pricing_strategy.yml"</span><span class="p">).</span><span class="nf">freeze</span>

  <span class="k">class</span> <span class="o">&lt;&lt;</span> <span class="nb">self</span>
    <span class="k">def</span> <span class="nf">active</span><span class="p">(</span><span class="ss">today: </span><span class="no">Time</span><span class="p">.</span><span class="nf">now</span><span class="p">.</span><span class="nf">utc</span><span class="p">.</span><span class="nf">to_date</span><span class="p">)</span>
      <span class="n">promo</span> <span class="o">=</span> <span class="n">strategy</span><span class="p">[</span><span class="s2">"promo"</span><span class="p">]</span> <span class="o">||</span> <span class="p">{}</span>
      <span class="nb">name</span> <span class="o">=</span> <span class="n">promo</span><span class="p">[</span><span class="s2">"name"</span><span class="p">]</span>
      <span class="k">return</span> <span class="kp">nil</span> <span class="k">if</span> <span class="nb">name</span><span class="p">.</span><span class="nf">blank?</span>

      <span class="n">start_date</span> <span class="o">=</span> <span class="n">promo</span><span class="p">[</span><span class="s2">"startDate"</span><span class="p">]</span>
      <span class="n">end_date</span> <span class="o">=</span> <span class="n">promo</span><span class="p">[</span><span class="s2">"endDate"</span><span class="p">]</span>
      <span class="k">return</span> <span class="kp">nil</span> <span class="k">if</span> <span class="n">start_date</span><span class="p">.</span><span class="nf">blank?</span> <span class="o">||</span> <span class="n">end_date</span><span class="p">.</span><span class="nf">blank?</span>

      <span class="n">date_window</span> <span class="o">=</span> <span class="no">Date</span><span class="p">.</span><span class="nf">iso8601</span><span class="p">(</span><span class="n">start_date</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span><span class="o">..</span><span class="no">Date</span><span class="p">.</span><span class="nf">iso8601</span><span class="p">(</span><span class="n">end_date</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span>
      <span class="k">return</span> <span class="kp">nil</span> <span class="k">unless</span> <span class="n">date_window</span><span class="p">.</span><span class="nf">cover?</span><span class="p">(</span><span class="n">today</span><span class="p">)</span>

      <span class="n">new</span><span class="p">(</span><span class="ss">name: </span><span class="nb">name</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The weather API response includes the promo field:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"forecast"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"promo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"happy10"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>To launch a campaign: update the YAML and deploy. To kill it: set <code class="language-plaintext highlighter-rouge">name: null</code> and deploy. No app update required.</p>

<h2 id="client-supported-promos">Client: Supported Promos</h2>

<p>The iOS app doesn’t blindly trust whatever the server sends. It maintains a set of supported promos:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@MainActor</span>
<span class="kd">class</span> <span class="kt">PromoManager</span><span class="p">:</span> <span class="kt">ObservableObject</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">shared</span> <span class="o">=</span> <span class="kt">PromoManager</span><span class="p">()</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">supportedPromos</span><span class="p">:</span> <span class="kt">Set</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">[</span><span class="s">"happy10"</span><span class="p">]</span>

    <span class="kd">private</span> <span class="kd">lazy</span> <span class="k">var</span> <span class="nv">weatherManager</span> <span class="o">=</span> <span class="kt">WeatherManager</span><span class="o">.</span><span class="n">shared</span>
    <span class="kd">private</span> <span class="kd">lazy</span> <span class="k">var</span> <span class="nv">storeManager</span> <span class="o">=</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="n">shared</span>

    <span class="k">var</span> <span class="nv">promoKey</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span> <span class="p">{</span>
        <span class="n">promoDebug</span> <span class="p">??</span> <span class="n">weatherManager</span><span class="o">.</span><span class="n">weather</span><span class="p">?</span><span class="o">.</span><span class="n">forecast</span><span class="p">?</span><span class="o">.</span><span class="n">promo</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">promoActive</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="n">storeManager</span><span class="o">.</span><span class="n">unpaid</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">false</span> <span class="p">}</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">promoKey</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">false</span> <span class="p">}</span>

        <span class="k">return</span> <span class="k">Self</span><span class="o">.</span><span class="n">supportedPromos</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">promoKey</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This two-layer check serves multiple purposes:</p>

<ol>
  <li><strong>Graceful rollout</strong> - Server can send a new promo key before the app supports it</li>
  <li><strong>Version safety</strong> - Old app versions ignore promos they don’t understand</li>
  <li><strong>Debug override</strong> - Testing promos locally without server changes</li>
</ol>

<h3 id="offer-codes">Offer Codes</h3>

<p>Each promo maps to specific App Store offer codes:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">offerCode</span> <span class="o">=</span> <span class="s">"HAPPY10"</span>
<span class="k">let</span> <span class="nv">offerCodeFamily</span> <span class="o">=</span> <span class="s">"HAPPY10FAM"</span>

<span class="k">var</span> <span class="nv">discountPercentage</span><span class="p">:</span> <span class="kt">Int</span> <span class="p">{</span>
    <span class="mi">50</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Users redeem these codes directly in the App Store - no eligibility check, no silent failures.</p>

<h3 id="dismissal-logic">Dismissal Logic</h3>

<p>Users can dismiss promo banners. We track dismissal with a cooldown:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">promoDismissedAt</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">get</span> <span class="p">{</span>
        <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">object</span><span class="p">(</span>
            <span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">promoDismissedAt</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="p">)</span> <span class="k">as?</span> <span class="kt">Date</span>
    <span class="p">}</span>
    <span class="k">set</span> <span class="p">{</span>
        <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span>
            <span class="n">newValue</span><span class="p">,</span>
            <span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">promoDismissedAt</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="p">)</span>
        <span class="n">objectWillChange</span><span class="o">.</span><span class="nf">send</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">showPromoTimeInterval</span><span class="p">:</span> <span class="kt">TimeInterval</span> <span class="p">{</span>
    <span class="mi">90</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="c1">// 90 days</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">showPromoNag</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="n">promoActive</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">false</span> <span class="p">}</span>
    <span class="k">guard</span> <span class="k">let</span> <span class="nv">promoDismissedAt</span> <span class="o">=</span> <span class="n">promoDismissedAt</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">true</span> <span class="p">}</span>

    <span class="k">return</span> <span class="kt">Date</span><span class="p">()</span> <span class="o">&gt;=</span> <span class="n">promoDismissedAt</span><span class="o">.</span><span class="nf">addingTimeInterval</span><span class="p">(</span><span class="n">showPromoTimeInterval</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="cli-offer-code-management">CLI: Offer Code Management</h2>

<p>Managing offer codes through App Store Connect’s web UI is tedious. We built a CLI skill and script system:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># List all configured offers and their ASC status</span>
bin/appstore offer list <span class="nt">--verbose</span>

<span class="c"># Preview what would be created</span>
bin/appstore offer apply happy10_yearly_single <span class="nt">--dry-run</span> <span class="nt">--verbose</span>

<span class="c"># Create the offer in App Store Connect</span>
bin/appstore offer apply happy10_yearly_single <span class="nt">--verbose</span>

<span class="c"># Verify ASC matches expected pricing</span>
bin/appstore offer verify happy10_yearly_single <span class="nt">--verbose</span>
</code></pre></div></div>

<h3 id="offer-configuration">Offer Configuration</h3>

<p>Offers are defined in the same pricing strategy file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/appstore/pricing_strategy.yml</span>
<span class="na">offer_codes</span><span class="pi">:</span>
  <span class="na">happy10_yearly_single</span><span class="pi">:</span>
    <span class="na">product_id</span><span class="pi">:</span> <span class="s">hw_v4_yearly_single</span>
    <span class="na">reference_name</span><span class="pi">:</span> <span class="s">HAPPY10_20260213</span>
    <span class="na">offer_mode</span><span class="pi">:</span> <span class="s">pay_up_front</span>
    <span class="na">duration</span><span class="pi">:</span> <span class="s">one_year</span>
    <span class="na">number_of_periods</span><span class="pi">:</span> <span class="m">1</span>
    <span class="na">customer_eligibilities</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">new</span><span class="pi">,</span> <span class="nv">existing</span><span class="pi">,</span> <span class="nv">expired</span><span class="pi">]</span>
    <span class="na">offer_eligibility</span><span class="pi">:</span> <span class="s">once</span>
    <span class="na">discount_percent</span><span class="pi">:</span> <span class="m">50</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>

  <span class="na">happy10_yearly_family</span><span class="pi">:</span>
    <span class="na">product_id</span><span class="pi">:</span> <span class="s">hw_v4_yearly_family</span>
    <span class="na">reference_name</span><span class="pi">:</span> <span class="s">HAPPY10FAM_20260213</span>
    <span class="na">offer_mode</span><span class="pi">:</span> <span class="s">pay_up_front</span>
    <span class="na">duration</span><span class="pi">:</span> <span class="s">one_year</span>
    <span class="na">number_of_periods</span><span class="pi">:</span> <span class="m">1</span>
    <span class="na">customer_eligibilities</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">new</span><span class="pi">,</span> <span class="nv">existing</span><span class="pi">,</span> <span class="nv">expired</span><span class="pi">]</span>
    <span class="na">offer_eligibility</span><span class="pi">:</span> <span class="s">once</span>
    <span class="na">discount_percent</span><span class="pi">:</span> <span class="m">50</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>Prices are computed from <code class="language-plaintext highlighter-rouge">approved_prices.yml</code> at runtime, applying the discount percentage to each territory’s base price.</p>

<h3 id="creating-redemption-codes">Creating Redemption Codes</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create a custom code with redemption limit</span>
bin/appstore offer codes custom happy10_yearly_single <span class="se">\</span>
  <span class="nt">--code</span> HAPPY10 <span class="se">\</span>
  <span class="nt">--limit</span> 5000 <span class="se">\</span>
  <span class="nt">--expires</span> 2026-03-31 <span class="se">\</span>
  <span class="nt">--verbose</span>

<span class="c"># Or create one-time codes for distribution</span>
bin/appstore offer codes one-time happy10_yearly_single <span class="se">\</span>
  <span class="nt">--count</span> 1000 <span class="se">\</span>
  <span class="nt">--expires</span> 2026-06-01 <span class="se">\</span>
  <span class="nt">--verbose</span>

<span class="c"># Download the generated codes</span>
bin/appstore offer codes values happy10_yearly_single <span class="se">\</span>
  <span class="nt">--batch-id</span> BATCH_ID <span class="se">\</span>
  <span class="nt">--output</span> tmp/codes.txt <span class="se">\</span>
  <span class="nt">--verbose</span>
</code></pre></div></div>

<h3 id="rollback">Rollback</h3>

<p>If something goes wrong:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Preview</span>
bin/appstore offer deactivate happy10_yearly_single <span class="nt">--dry-run</span>

<span class="c"># Deactivate</span>
bin/appstore offer deactivate happy10_yearly_single
</code></pre></div></div>

<h2 id="campaign-launch-workflow">Campaign Launch Workflow</h2>

<p>A complete campaign launch:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Refresh App Store Connect data</span>
bin/appstore refresh <span class="nt">--verbose</span>

<span class="c"># 2. Validate pricing</span>
bin/appstore validate <span class="nt">--verbose</span>

<span class="c"># 3. Review what will be created</span>
bin/appstore offer plan happy10_yearly_single <span class="nt">--verbose</span>

<span class="c"># 4. Create offers</span>
bin/appstore offer apply happy10_yearly_single <span class="nt">--verbose</span>
bin/appstore offer apply happy10_yearly_family <span class="nt">--verbose</span>

<span class="c"># 5. Create redemption codes</span>
bin/appstore offer codes custom happy10_yearly_single <span class="se">\</span>
  <span class="nt">--code</span> HAPPY10 <span class="nt">--limit</span> 5000 <span class="nt">--expires</span> 2026-03-31 <span class="nt">--verbose</span>
bin/appstore offer codes custom happy10_yearly_family <span class="se">\</span>
  <span class="nt">--code</span> HAPPY10FAM <span class="nt">--limit</span> 5000 <span class="nt">--expires</span> 2026-03-31 <span class="nt">--verbose</span>

<span class="c"># 6. Verify everything matches</span>
bin/appstore offer verify happy10_yearly_single <span class="nt">--verbose</span>
bin/appstore offer verify happy10_yearly_family <span class="nt">--verbose</span>

<span class="c"># 7. Update server config and deploy</span>
<span class="c"># config/appstore/pricing_strategy.yml</span>
<span class="c">#   promo:</span>
<span class="c">#     name: happy10</span>
<span class="c">#     startDate: "2026-03-01"</span>
<span class="c">#     endDate: "2026-03-31"</span>
</code></pre></div></div>

<h2 id="why-this-works">Why This Works</h2>

<p><strong>Offer codes solve eligibility</strong>: Unlike introductory offers, anyone can redeem an offer code. No silent failures, no confused users.</p>

<p><strong>Server control enables agility</strong>: Launch campaigns with a deploy, not an app update. End them instantly if needed.</p>

<p><strong>Supported promos enable safety</strong>: Old app versions gracefully ignore new campaigns. New campaigns can be tested before the app officially supports them.</p>

<p><strong>CLI tooling reduces errors</strong>: Scripted offer management is repeatable and auditable. No clicking through ASC forms.</p>

<p>The system has successfully run multiple campaigns with zero eligibility-related support tickets.</p>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt:</strong> “create a new post about our promo system, see previous commits, note the new promo system uses ‘offer codes’ to avoid eligibility issues, such as users who previously subscribed or even had a trial were ineligible, which could not be detected with storekit2. note the lightweight server component, so we can enable/disable promos server-side. note the client side has ‘supported promos’ so we can add support and adjust UI elements etc over time. note also the appstore skill+script system which lets us manage offer codes etc with appstoreconnect. create a pr for this new post.”</p>

<p>Generated by Claude using the blog-post-generator skill. Based on production code from Hello Weather’s promo system.</p>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="storekit" /><category term="promotions" /><category term="ruby" /><category term="cli" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">StoreKit 2 Implementation Guide</title><link href="https://trevorturk.github.io/storekit-2-implementation/" rel="alternate" type="text/html" title="StoreKit 2 Implementation Guide" /><published>2026-03-05T14:00:00+00:00</published><updated>2026-03-05T14:00:00+00:00</updated><id>https://trevorturk.github.io/storekit-2-implementation</id><content type="html" xml:base="https://trevorturk.github.io/storekit-2-implementation/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Implementing in-app purchases correctly is surprisingly complex. You need to:</p>

<ol>
  <li><strong>Verify transactions cryptographically</strong> - Don’t just trust purchase claims</li>
  <li><strong>Monitor in real-time</strong> - Catch renewals, cancellations, and refunds as they happen</li>
  <li><strong>Persist state properly</strong> - Share entitlements across app, widgets, and watch</li>
  <li><strong>Handle edge cases</strong> - Interrupted purchases, family sharing, sandbox testing</li>
  <li><strong>Build paywall UI</strong> - Clean integration with SwiftUI and the new ProductView</li>
</ol>

<p>StoreKit 2 simplifies much of this, but the documentation lacks complete production examples.</p>

<h2 id="the-solution">The Solution</h2>

<p>We built a three-layer architecture:</p>

<ul>
  <li><strong>StoreService</strong> - Handles StoreKit API interactions, transaction verification, and real-time monitoring</li>
  <li><strong>StoreManager</strong> - Manages state, persists transactions, and provides computed entitlements</li>
  <li><strong>TransactionRecord</strong> - Codable model for persisting transaction data</li>
</ul>

<p>This separation keeps the StoreKit complexity isolated from business logic.</p>

<h2 id="implementation">Implementation</h2>

<h3 id="storeservice-the-api-layer">StoreService: The API Layer</h3>

<p>StoreService handles all StoreKit 2 interactions. It’s marked <code class="language-plaintext highlighter-rouge">@MainActor</code> for thread safety:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">StoreKit</span>

<span class="kd">@MainActor</span>
<span class="kd">class</span> <span class="kt">StoreService</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">shared</span> <span class="o">=</span> <span class="kt">StoreService</span><span class="p">()</span>

    <span class="kd">private</span> <span class="kd">lazy</span> <span class="k">var</span> <span class="nv">storeManager</span> <span class="o">=</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="n">shared</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">transactionUpdatesTask</span><span class="p">:</span> <span class="kt">Task</span><span class="o">&lt;</span><span class="kt">Void</span><span class="p">,</span> <span class="kt">Never</span><span class="o">&gt;</span><span class="p">?</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">subscriptionStatusUpdatesTask</span><span class="p">:</span> <span class="kt">Task</span><span class="o">&lt;</span><span class="kt">Void</span><span class="p">,</span> <span class="kt">Never</span><span class="o">&gt;</span><span class="p">?</span>

    <span class="kd">func</span> <span class="nf">activate</span><span class="p">()</span> <span class="p">{</span>
        <span class="kt">Task</span> <span class="p">{</span>
            <span class="k">await</span> <span class="nf">observeTransactionUpdates</span><span class="p">()</span>
            <span class="k">await</span> <span class="nf">observeSubscriptionStatusUpdates</span><span class="p">()</span>
            <span class="k">await</span> <span class="nf">checkForUnfinishedTransactions</span><span class="p">()</span>
            <span class="k">await</span> <span class="nf">updateCurrentEntitlements</span><span class="p">()</span>
            <span class="k">await</span> <span class="nf">fetchProducts</span><span class="p">()</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The activation sequence runs in order:</p>
<ol>
  <li>Start observing real-time updates</li>
  <li>Process any interrupted purchases</li>
  <li>Sync current entitlements</li>
  <li>Pre-fetch product info for paywalls</li>
</ol>

<h3 id="real-time-transaction-monitoring">Real-Time Transaction Monitoring</h3>

<p>StoreKit 2 provides async sequences for transaction updates:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">observeTransactionUpdates</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="k">self</span><span class="o">.</span><span class="n">transactionUpdatesTask</span> <span class="o">=</span> <span class="kt">Task</span> <span class="p">{</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="k">in</span>
        <span class="k">for</span> <span class="k">await</span> <span class="n">verificationResult</span> <span class="k">in</span> <span class="kt">Transaction</span><span class="o">.</span><span class="n">updates</span> <span class="p">{</span>
            <span class="k">guard</span> <span class="k">let</span> <span class="nv">self</span> <span class="k">else</span> <span class="p">{</span> <span class="k">break</span> <span class="p">}</span>
            <span class="k">await</span> <span class="k">self</span><span class="o">.</span><span class="nf">process</span><span class="p">(</span><span class="n">verificationResult</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">observeSubscriptionStatusUpdates</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="n">subscriptionStatusUpdatesTask</span> <span class="o">=</span> <span class="kt">Task</span> <span class="p">{</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="k">in</span>
        <span class="k">for</span> <span class="k">await</span> <span class="n">status</span> <span class="k">in</span> <span class="kt">StoreKit</span><span class="o">.</span><span class="kt">Product</span><span class="o">.</span><span class="kt">SubscriptionInfo</span><span class="o">.</span><span class="kt">Status</span><span class="o">.</span><span class="n">updates</span> <span class="p">{</span>
            <span class="k">guard</span> <span class="k">let</span> <span class="nv">self</span> <span class="k">else</span> <span class="p">{</span> <span class="k">break</span> <span class="p">}</span>
            <span class="k">await</span> <span class="k">self</span><span class="o">.</span><span class="nf">process</span><span class="p">(</span><span class="n">status</span><span class="o">.</span><span class="n">transaction</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>These run continuously, catching renewals, cancellations, and refunds even when the app is backgrounded.</p>

<h3 id="transaction-verification-and-processing">Transaction Verification and Processing</h3>

<p>Every transaction goes through cryptographic verification:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">process</span><span class="p">(</span><span class="n">_</span> <span class="nv">verificationResult</span><span class="p">:</span> <span class="kt">VerificationResult</span><span class="o">&lt;</span><span class="kt">Transaction</span><span class="o">&gt;</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="n">verificationResult</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">verified</span><span class="p">(</span><span class="k">let</span> <span class="nv">transaction</span><span class="p">):</span>
        <span class="k">let</span> <span class="nv">renewalInfo</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetchRenewalInfo</span><span class="p">(</span><span class="n">transaction</span><span class="p">)</span>
        <span class="n">storeManager</span><span class="o">.</span><span class="nf">process</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="n">transaction</span><span class="p">,</span> <span class="nv">renewalInfo</span><span class="p">:</span> <span class="n">renewalInfo</span><span class="p">)</span>
        <span class="k">await</span> <span class="n">transaction</span><span class="o">.</span><span class="nf">finish</span><span class="p">()</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">unverified</span><span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="k">let</span> <span class="nv">error</span><span class="p">):</span>
        <span class="c1">// Log but don't crash - could be jailbroken device or corruption</span>
        <span class="kt">Logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">fetchRenewalInfo</span><span class="p">(</span><span class="n">_</span> <span class="nv">transaction</span><span class="p">:</span> <span class="kt">Transaction</span><span class="p">)</span> <span class="k">async</span>
    <span class="o">-&gt;</span> <span class="kt">StoreKit</span><span class="o">.</span><span class="kt">Product</span><span class="o">.</span><span class="kt">SubscriptionInfo</span><span class="o">.</span><span class="kt">RenewalInfo</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="k">let</span> <span class="nv">verificationResult</span> <span class="o">=</span> <span class="k">await</span> <span class="n">transaction</span><span class="o">.</span><span class="n">subscriptionStatus</span><span class="p">?</span><span class="o">.</span><span class="n">renewalInfo</span>
        <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">nil</span> <span class="p">}</span>

    <span class="k">switch</span> <span class="n">verificationResult</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">verified</span><span class="p">(</span><span class="k">let</span> <span class="nv">renewalInfo</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">renewalInfo</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">unverified</span><span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="k">let</span> <span class="nv">error</span><span class="p">):</span>
        <span class="kt">Logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="p">)</span>
        <span class="k">return</span> <span class="kc">nil</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">renewalInfo</code> tells you whether the subscription will auto-renew - critical for showing “Cancelling” vs “Subscribed” status.</p>

<h3 id="handling-unfinished-transactions">Handling Unfinished Transactions</h3>

<p>App Store holds transactions until you call <code class="language-plaintext highlighter-rouge">finish()</code>. If the app crashes mid-purchase, these accumulate:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">checkForUnfinishedTransactions</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="k">for</span> <span class="k">await</span> <span class="n">verificationResult</span> <span class="k">in</span> <span class="kt">Transaction</span><span class="o">.</span><span class="n">unfinished</span> <span class="p">{</span>
        <span class="k">await</span> <span class="k">self</span><span class="o">.</span><span class="nf">process</span><span class="p">(</span><span class="n">verificationResult</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">updateCurrentEntitlements</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="k">for</span> <span class="k">await</span> <span class="n">verificationResult</span> <span class="k">in</span> <span class="kt">Transaction</span><span class="o">.</span><span class="n">currentEntitlements</span> <span class="p">{</span>
        <span class="k">await</span> <span class="k">self</span><span class="o">.</span><span class="nf">process</span><span class="p">(</span><span class="n">verificationResult</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">storeManager</span><span class="o">.</span><span class="n">hasUpdatedCurrentEntitlements</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="n">storeManager</span><span class="o">.</span><span class="nf">transactionsDidChange</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Always process unfinished transactions on app launch.</p>

<h3 id="restore-purchases">Restore Purchases</h3>

<p>Users expect “Restore Purchases” to work, especially on new devices:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">restorePurchases</span><span class="p">()</span> <span class="k">async</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">do</span> <span class="p">{</span>
        <span class="k">try</span> <span class="k">await</span> <span class="kt">AppStore</span><span class="o">.</span><span class="nf">sync</span><span class="p">()</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
        <span class="kt">Logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="p">)</span>
        <span class="k">return</span> <span class="kc">false</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">restored</span><span class="p">:</span> <span class="p">[</span><span class="kt">TransactionRecord</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="k">for</span> <span class="k">await</span> <span class="n">verificationResult</span> <span class="k">in</span> <span class="kt">Transaction</span><span class="o">.</span><span class="n">all</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">verificationResult</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="nf">verified</span><span class="p">(</span><span class="k">let</span> <span class="nv">transaction</span><span class="p">):</span>
            <span class="k">let</span> <span class="nv">renewalInfo</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetchRenewalInfo</span><span class="p">(</span><span class="n">transaction</span><span class="p">)</span>
            <span class="n">restored</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">TransactionRecord</span><span class="p">(</span>
                <span class="nv">transaction</span><span class="p">:</span> <span class="n">transaction</span><span class="p">,</span>
                <span class="nv">renewalInfo</span><span class="p">:</span> <span class="n">renewalInfo</span>
            <span class="p">))</span>
            <span class="k">await</span> <span class="n">transaction</span><span class="o">.</span><span class="nf">finish</span><span class="p">()</span>
        <span class="k">case</span> <span class="o">.</span><span class="nf">unverified</span><span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="k">let</span> <span class="nv">error</span><span class="p">):</span>
            <span class="kt">Logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="n">storeManager</span><span class="o">.</span><span class="n">hasUpdatedCurrentEntitlements</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="n">storeManager</span><span class="o">.</span><span class="nf">replaceTransactions</span><span class="p">(</span><span class="n">restored</span><span class="p">)</span>
    <span class="k">return</span> <span class="kc">true</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">AppStore.sync()</code> triggers Sign in with Apple ID if needed. Then we iterate all transactions and rebuild our local state.</p>

<h3 id="pre-fetching-products">Pre-fetching Products</h3>

<p>For fast paywall loading, fetch products early:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">fetchProducts</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="n">storeManager</span><span class="o">.</span><span class="n">unpaid</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

    <span class="k">do</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">products</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">StoreKit</span><span class="o">.</span><span class="kt">Product</span><span class="o">.</span><span class="nf">products</span><span class="p">(</span>
            <span class="nv">for</span><span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">allPaywall</span>
        <span class="p">)</span>
        <span class="n">storeManager</span><span class="o">.</span><span class="n">products</span> <span class="o">=</span> <span class="n">products</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
        <span class="kt">Logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="s">"fetchProducts: </span><span class="se">\(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Skip this for paid users - they won’t see the paywall anyway.</p>

<hr />

<h2 id="storemanager-state-and-persistence">StoreManager: State and Persistence</h2>

<p>StoreManager is the single source of truth for purchase state:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">import</span> <span class="kt">StoreKit</span>

<span class="kd">@MainActor</span>
<span class="kd">class</span> <span class="kt">StoreManager</span><span class="p">:</span> <span class="kt">ObservableObject</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">shared</span> <span class="o">=</span> <span class="kt">StoreManager</span><span class="p">()</span>

    <span class="kd">private</span> <span class="kd">lazy</span> <span class="k">var</span> <span class="nv">savedDataManager</span> <span class="o">=</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="n">shared</span>

    <span class="k">var</span> <span class="nv">products</span><span class="p">:</span> <span class="p">[</span><span class="kt">StoreKit</span><span class="o">.</span><span class="kt">Product</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span> <span class="p">{</span>
        <span class="k">didSet</span> <span class="p">{</span> <span class="n">objectWillChange</span><span class="o">.</span><span class="nf">send</span><span class="p">()</span> <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="k">let</span> <span class="nv">maxTransactions</span> <span class="o">=</span> <span class="mi">9999</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="defining-product-ids">Defining Product IDs</h3>

<p>Organize product IDs as static properties for type safety:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">Plan</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">monthly</span>  <span class="o">=</span> <span class="s">"hw_v4_monthly_single"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">yearly</span>   <span class="o">=</span> <span class="s">"hw_v4_yearly_single"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">lifetime</span> <span class="o">=</span> <span class="s">"hw_v4_lifetime_single"</span>

    <span class="kd">static</span> <span class="k">let</span> <span class="nv">monthly_family</span>  <span class="o">=</span> <span class="s">"hw_v4_monthly_family"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">yearly_family</span>   <span class="o">=</span> <span class="s">"hw_v4_yearly_family"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">lifetime_family</span> <span class="o">=</span> <span class="s">"hw_v4_lifetime_family"</span>

    <span class="c1">// Legacy plans for migration support</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">v3_monthly_1</span> <span class="o">=</span> <span class="s">"hw_monthly_099"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">v3_monthly_2</span> <span class="o">=</span> <span class="s">"hw_1_month_auto"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">v3_yearly_1</span>  <span class="o">=</span> <span class="s">"hw_1_year_auto"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">v3_yearly_2</span>  <span class="o">=</span> <span class="s">"hw_1_year_auto_2"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">v3_lifetime_1</span> <span class="o">=</span> <span class="s">"hw_lifetime_499"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">v3_lifetime_2</span> <span class="o">=</span> <span class="s">"hw_lifetime_299"</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">allActive</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="p">[</span>
            <span class="n">monthly</span><span class="p">,</span> <span class="n">yearly</span><span class="p">,</span> <span class="n">lifetime</span><span class="p">,</span>
            <span class="n">monthly_family</span><span class="p">,</span> <span class="n">yearly_family</span><span class="p">,</span> <span class="n">lifetime_family</span><span class="p">,</span>
            <span class="n">v3_monthly_1</span><span class="p">,</span> <span class="n">v3_monthly_2</span><span class="p">,</span> <span class="n">v3_yearly_1</span><span class="p">,</span> <span class="n">v3_yearly_2</span><span class="p">,</span>
            <span class="n">v3_lifetime_1</span><span class="p">,</span> <span class="n">v3_lifetime_2</span><span class="p">,</span>
        <span class="p">]</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">allLifetime</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="p">[</span><span class="n">lifetime</span><span class="p">,</span> <span class="n">lifetime_family</span><span class="p">,</span> <span class="n">v3_lifetime_1</span><span class="p">,</span> <span class="n">v3_lifetime_2</span><span class="p">]</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">paywallIndividual</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="p">[</span><span class="n">monthly</span><span class="p">,</span> <span class="n">yearly</span><span class="p">,</span> <span class="n">lifetime</span><span class="p">]</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">paywallFamily</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="p">[</span><span class="n">monthly_family</span><span class="p">,</span> <span class="n">yearly_family</span><span class="p">,</span> <span class="n">lifetime_family</span><span class="p">]</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">allPaywall</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="n">paywallIndividual</span> <span class="o">+</span> <span class="n">paywallFamily</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This approach makes it easy to add new products while maintaining backwards compatibility with legacy purchases.</p>

<h3 id="the-paid-flag">The Paid Flag</h3>

<p>The <code class="language-plaintext highlighter-rouge">paid</code> boolean is the primary entitlement check:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">paid</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">get</span> <span class="p">{</span>
        <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">bool</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">paid</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">set</span> <span class="p">{</span>
        <span class="nf">handlePaidChange</span><span class="p">(</span><span class="nv">oldValue</span><span class="p">:</span> <span class="n">paid</span><span class="p">,</span> <span class="nv">newValue</span><span class="p">:</span> <span class="n">newValue</span><span class="p">)</span>
        <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">newValue</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">paid</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="n">objectWillChange</span><span class="o">.</span><span class="nf">send</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">unpaid</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="o">!</span><span class="n">paid</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Use <code class="language-plaintext highlighter-rouge">@ObservedObject</code> and <code class="language-plaintext highlighter-rouge">unpaid</code> for feature gating in SwiftUI:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">storeManager</span><span class="o">.</span><span class="n">unpaid</span> <span class="p">{</span>
    <span class="kt">Button</span><span class="p">(</span><span class="s">"Upgrade to Pro"</span><span class="p">)</span> <span class="p">{</span> <span class="n">showPaywall</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="handling-state-transitions">Handling State Transitions</h3>

<p>When paid status changes, update app behavior:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">handlePaidChange</span><span class="p">(</span><span class="nv">oldValue</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span> <span class="nv">newValue</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">(</span><span class="n">oldValue</span><span class="p">,</span> <span class="n">newValue</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="p">(</span><span class="kc">false</span><span class="p">,</span> <span class="kc">true</span><span class="p">):</span>
        <span class="c1">// User just subscribed</span>
        <span class="k">if</span> <span class="n">settingsManager</span><span class="o">.</span><span class="n">showOnboarding</span> <span class="p">{</span>
            <span class="n">settingsManager</span><span class="o">.</span><span class="n">apiSource</span> <span class="o">=</span> <span class="n">settingsManager</span><span class="o">.</span><span class="nf">apiSourceDefault</span><span class="p">(</span><span class="nv">paid</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
            <span class="n">syncService</span><span class="o">.</span><span class="nf">sync</span><span class="p">()</span>
        <span class="p">}</span>

    <span class="k">case</span> <span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="kc">false</span><span class="p">):</span>
        <span class="c1">// Subscription expired or refunded</span>
        <span class="k">guard</span> <span class="n">hasUpdatedCurrentEntitlements</span> <span class="k">else</span> <span class="p">{</span>
            <span class="c1">// Don't downgrade until we've synced with App Store</span>
            <span class="k">return</span>
        <span class="p">}</span>

        <span class="n">settingsManager</span><span class="o">.</span><span class="n">apiSource</span> <span class="o">=</span> <span class="n">settingsManager</span><span class="o">.</span><span class="nf">apiSourceDefault</span><span class="p">(</span><span class="nv">paid</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span>
        <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">radarLayer</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="k">await</span> <span class="kt">PushManager</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">pushEnabledChanged</span><span class="p">()</span>
        <span class="n">syncService</span><span class="o">.</span><span class="nf">sync</span><span class="p">()</span>

    <span class="k">default</span><span class="p">:</span>
        <span class="k">break</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">hasUpdatedCurrentEntitlements</code> guard prevents false downgrades before the initial sync completes.</p>

<h3 id="transaction-persistence">Transaction Persistence</h3>

<p>Store transactions in UserDefaults with app groups for widget/watch access:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">transactions</span><span class="p">:</span> <span class="p">[</span><span class="kt">TransactionRecord</span><span class="p">]</span> <span class="p">{</span>
    <span class="k">get</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">val</span> <span class="o">=</span> <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span>
            <span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">transactions</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">[]</span> <span class="p">}</span>
        <span class="nf">return</span> <span class="p">(</span><span class="k">try</span><span class="p">?</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">(</span>
            <span class="p">[</span><span class="kt">TransactionRecord</span><span class="p">]</span><span class="o">.</span><span class="k">self</span><span class="p">,</span>
            <span class="nv">from</span><span class="p">:</span> <span class="n">val</span>
        <span class="p">))</span> <span class="p">??</span> <span class="p">[]</span>
    <span class="p">}</span>
    <span class="k">set</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">normalized</span> <span class="o">=</span> <span class="nf">normalizedTransactions</span><span class="p">(</span><span class="n">newValue</span><span class="p">)</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">val</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">JSONEncoder</span><span class="p">()</span><span class="o">.</span><span class="nf">encode</span><span class="p">(</span><span class="n">normalized</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">savedDataManager</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">val</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="kt">SavedDataManager</span><span class="o">.</span><span class="kt">Keys</span><span class="o">.</span><span class="n">transactions</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="nf">transactionsDidChange</span><span class="p">()</span>
        <span class="n">objectWillChange</span><span class="o">.</span><span class="nf">send</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">process</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="kt">Transaction</span><span class="p">,</span>
             <span class="nv">renewalInfo</span><span class="p">:</span> <span class="kt">StoreKit</span><span class="o">.</span><span class="kt">Product</span><span class="o">.</span><span class="kt">SubscriptionInfo</span><span class="o">.</span><span class="kt">RenewalInfo</span><span class="p">?)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">record</span> <span class="o">=</span> <span class="kt">TransactionRecord</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="n">transaction</span><span class="p">,</span> <span class="nv">renewalInfo</span><span class="p">:</span> <span class="n">renewalInfo</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">updated</span> <span class="o">=</span> <span class="n">transactions</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">id</span> <span class="o">!=</span> <span class="n">record</span><span class="o">.</span><span class="n">id</span> <span class="p">}</span>
    <span class="n">updated</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">record</span><span class="p">)</span>
    <span class="n">transactions</span> <span class="o">=</span> <span class="n">updated</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">transactionsDidChange</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">paid</span> <span class="o">=</span> <span class="n">activeTransactions</span><span class="o">.</span><span class="n">isNotEmpty</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="computed-entitlement-properties">Computed Entitlement Properties</h3>

<p>Derive all subscription state from the transaction array:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">activeTransactions</span><span class="p">:</span> <span class="p">[</span><span class="kt">TransactionRecord</span><span class="p">]</span> <span class="p">{</span>
    <span class="n">transactions</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">active</span> <span class="o">==</span> <span class="kc">true</span> <span class="p">}</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">inActiveTransactions</span><span class="p">:</span> <span class="p">[</span><span class="kt">TransactionRecord</span><span class="p">]</span> <span class="p">{</span>
    <span class="n">transactions</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">active</span> <span class="o">==</span> <span class="kc">false</span> <span class="p">}</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">paidLifetime</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="n">activeTransactions</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">lifetime</span> <span class="o">==</span> <span class="kc">true</span> <span class="p">}</span><span class="o">.</span><span class="n">isNotEmpty</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">expirationDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="n">paidLifetime</span> <span class="o">==</span> <span class="kc">false</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">nil</span> <span class="p">}</span>
    <span class="k">return</span> <span class="n">activeTransactions</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">expirationDate</span> <span class="p">}</span><span class="o">.</span><span class="nf">max</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">originalPurchaseDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">activeTransactions</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">originalPurchaseDate</span> <span class="p">}</span><span class="o">.</span><span class="nf">min</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">willAutoRenew</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="n">paidLifetime</span> <span class="o">==</span> <span class="kc">false</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">false</span> <span class="p">}</span>
    <span class="k">return</span> <span class="n">activeTransactions</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">willAutoRenew</span> <span class="o">==</span> <span class="kc">true</span> <span class="p">}</span><span class="o">.</span><span class="n">isNotEmpty</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="detailed-paid-status">Detailed Paid Status</h3>

<p>Show users exactly what’s happening with their subscription:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">PaidStatus</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">lifetime</span> <span class="o">=</span> <span class="s">"Lifetime"</span>
    <span class="k">case</span> <span class="n">subscribed</span> <span class="o">=</span> <span class="s">"Subscribed"</span>   <span class="c1">// Active, will auto-renew</span>
    <span class="k">case</span> <span class="n">cancelling</span> <span class="o">=</span> <span class="s">"Cancelling"</span>    <span class="c1">// Active, won't renew</span>
    <span class="k">case</span> <span class="n">cancelled</span> <span class="o">=</span> <span class="s">"Cancelled"</span>      <span class="c1">// Expired</span>
    <span class="k">case</span> <span class="n">unpaid</span> <span class="o">=</span> <span class="s">"Unpaid"</span>            <span class="c1">// Never purchased</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">paidStatus</span><span class="p">:</span> <span class="kt">PaidStatus</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">paidLifetime</span> <span class="p">{</span>
        <span class="k">return</span> <span class="o">.</span><span class="n">lifetime</span>
    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="n">willAutoRenew</span> <span class="p">{</span>
        <span class="k">return</span> <span class="o">.</span><span class="n">subscribed</span>
    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="n">paid</span> <span class="p">{</span>
        <span class="k">return</span> <span class="o">.</span><span class="n">cancelling</span>
    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="n">hasPaid</span> <span class="p">{</span>
        <span class="k">return</span> <span class="o">.</span><span class="n">cancelled</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span> <span class="o">.</span><span class="n">unpaid</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="transactionrecord-the-persistence-model">TransactionRecord: The Persistence Model</h2>

<p>Store everything needed to determine entitlement without calling StoreKit:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">import</span> <span class="kt">StoreKit</span>

<span class="kd">struct</span> <span class="kt">TransactionRecord</span><span class="p">:</span> <span class="kt">Codable</span><span class="p">,</span> <span class="kt">Identifiable</span><span class="p">,</span> <span class="kt">Equatable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">environment</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">id</span><span class="p">:</span> <span class="kt">UInt64</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">originalID</span><span class="p">:</span> <span class="kt">UInt64</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">webOrderLineItemID</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">productID</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">productType</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">purchaseDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">originalPurchaseDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">expirationDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">revocationDate</span><span class="p">:</span> <span class="kt">Date</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">revocationReason</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">ownershipType</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">willAutoRenew</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">currency</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">price</span><span class="p">:</span> <span class="kt">Decimal</span><span class="p">?</span>

    <span class="nf">init</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="kt">Transaction</span><span class="p">,</span>
         <span class="nv">renewalInfo</span><span class="p">:</span> <span class="kt">StoreKit</span><span class="o">.</span><span class="kt">Product</span><span class="o">.</span><span class="kt">SubscriptionInfo</span><span class="o">.</span><span class="kt">RenewalInfo</span><span class="p">?)</span> <span class="p">{</span>
        <span class="k">self</span><span class="o">.</span><span class="n">environment</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">environment</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="k">self</span><span class="o">.</span><span class="n">id</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">id</span>
        <span class="k">self</span><span class="o">.</span><span class="n">originalID</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">originalID</span>
        <span class="k">self</span><span class="o">.</span><span class="n">webOrderLineItemID</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">webOrderLineItemID</span>
        <span class="k">self</span><span class="o">.</span><span class="n">productID</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">productID</span>
        <span class="k">self</span><span class="o">.</span><span class="n">productType</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">productType</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="k">self</span><span class="o">.</span><span class="n">purchaseDate</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">purchaseDate</span>
        <span class="k">self</span><span class="o">.</span><span class="n">originalPurchaseDate</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">originalPurchaseDate</span>
        <span class="k">self</span><span class="o">.</span><span class="n">expirationDate</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">expirationDate</span>
        <span class="k">self</span><span class="o">.</span><span class="n">revocationDate</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">revocationDate</span>
        <span class="k">self</span><span class="o">.</span><span class="n">revocationReason</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">revocationReason</span><span class="p">?</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="k">self</span><span class="o">.</span><span class="n">ownershipType</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">ownershipType</span><span class="o">.</span><span class="n">rawValue</span>
        <span class="k">self</span><span class="o">.</span><span class="n">willAutoRenew</span> <span class="o">=</span> <span class="n">renewalInfo</span><span class="p">?</span><span class="o">.</span><span class="n">willAutoRenew</span>
        <span class="k">self</span><span class="o">.</span><span class="n">currency</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">currency</span><span class="p">?</span><span class="o">.</span><span class="n">identifier</span>
        <span class="k">self</span><span class="o">.</span><span class="n">price</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">price</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="determining-active-status">Determining Active Status</h3>

<p>A transaction is active if:</p>
<ul>
  <li>Product ID is in our list of valid products</li>
  <li>Not revoked (refunded)</li>
  <li>Not expired (for subscriptions)</li>
</ul>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">active</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">allActive</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">productID</span> <span class="p">??</span> <span class="s">""</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">false</span>
    <span class="p">}</span>
    <span class="k">guard</span> <span class="n">revocationDate</span> <span class="o">==</span> <span class="kc">nil</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">false</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="k">let</span> <span class="nv">expirationDate</span> <span class="o">=</span> <span class="n">expirationDate</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">Date</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">expirationDate</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="kc">true</span>  <span class="c1">// Lifetime purchase with no expiration</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">inactive</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="o">!</span><span class="n">active</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">lifetime</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="n">active</span> <span class="o">&amp;&amp;</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">allLifetime</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">productID</span> <span class="p">??</span> <span class="s">""</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="revocation-handling">Revocation Handling</h3>

<p>Track why a transaction was revoked:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">revocationReasonString</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="n">revocationReason</span> <span class="p">{</span>
    <span class="k">case</span> <span class="mi">0</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Canceled"</span>
    <span class="k">case</span> <span class="mi">1</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Billing issue"</span>
    <span class="k">case</span> <span class="mi">2</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Upgrade/Downgrade"</span>
    <span class="k">case</span> <span class="mi">3</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Refunded"</span>
    <span class="k">case</span> <span class="mi">4</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Suspected fraud"</span>
    <span class="k">case</span> <span class="mi">5</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Pricing expired"</span>
    <span class="k">default</span><span class="p">:</span> <span class="k">return</span> <span class="s">"Unknown"</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="paywall-ui-with-productview">Paywall UI with ProductView</h2>

<p>StoreKit 2 provides <code class="language-plaintext highlighter-rouge">ProductView</code> for purchasing UI. Wrap it with custom styles:</p>

<h3 id="the-main-paywall">The Main Paywall</h3>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">SwiftUI</span>
<span class="kd">import</span> <span class="kt">StoreKit</span>

<span class="kd">@MainActor</span>
<span class="kd">struct</span> <span class="kt">PaywallView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@ObservedObject</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">storeManager</span> <span class="o">=</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="n">shared</span>

    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">showPlans</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">showRestoreNoPurchases</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">showRestoreSyncFailed</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">16</span><span class="p">)</span> <span class="p">{</span>
            <span class="c1">// Hero content...</span>

            <span class="kt">Text</span><span class="p">(</span><span class="s">"Try 1 week free, then </span><span class="se">\(</span><span class="n">storeManager</span><span class="o">.</span><span class="n">yearlyDisplayPrice</span> <span class="p">??</span> <span class="s">"..."</span><span class="se">)</span><span class="s">/year"</span><span class="p">)</span>

            <span class="kt">ProductView</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">yearly</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">productViewStyle</span><span class="p">(</span><span class="kt">TrialButton</span><span class="p">(</span><span class="nv">buttonText</span><span class="p">:</span> <span class="s">"Try 1 week free"</span><span class="p">))</span>

            <span class="kt">HStack</span> <span class="p">{</span>
                <span class="kt">Text</span><span class="p">(</span><span class="s">"See All Plans"</span><span class="p">)</span>
                    <span class="o">.</span><span class="n">onTapGesture</span> <span class="p">{</span> <span class="n">showPlans</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">}</span>

                <span class="kt">Text</span><span class="p">(</span><span class="s">" • "</span><span class="p">)</span>

                <span class="kt">Button</span><span class="p">(</span><span class="s">"Restore"</span><span class="p">)</span> <span class="p">{</span>
                    <span class="kt">Task</span> <span class="p">{</span>
                        <span class="k">let</span> <span class="nv">synced</span> <span class="o">=</span> <span class="k">await</span> <span class="kt">StoreService</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">restorePurchases</span><span class="p">()</span>

                        <span class="k">if</span> <span class="o">!</span><span class="n">synced</span> <span class="p">{</span>
                            <span class="n">showRestoreSyncFailed</span> <span class="o">=</span> <span class="kc">true</span>
                        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="n">storeManager</span><span class="o">.</span><span class="n">paid</span> <span class="o">==</span> <span class="kc">false</span> <span class="p">{</span>
                            <span class="n">showRestoreNoPurchases</span> <span class="o">=</span> <span class="kc">true</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">alert</span><span class="p">(</span><span class="s">"No active purchases found."</span><span class="p">,</span> <span class="nv">isPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">showRestoreNoPurchases</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Button</span><span class="p">(</span><span class="s">"Done"</span><span class="p">,</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{})</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">alert</span><span class="p">(</span><span class="s">"Couldn't connect to App Store."</span><span class="p">,</span> <span class="nv">isPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">showRestoreSyncFailed</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Button</span><span class="p">(</span><span class="s">"Done"</span><span class="p">,</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{})</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">storeManager</span><span class="o">.</span><span class="n">paid</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">guard</span> <span class="n">storeManager</span><span class="o">.</span><span class="n">paid</span> <span class="o">==</span> <span class="kc">true</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
            <span class="c1">// Dismiss paywall on successful purchase</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="custom-productviewstyle">Custom ProductViewStyle</h3>

<p>Create a styled purchase button:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">TrialButton</span><span class="p">:</span> <span class="kt">ProductViewStyle</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">buttonText</span><span class="p">:</span> <span class="kt">LocalizedStringKey</span> <span class="o">=</span> <span class="s">"Try 1 week free"</span>

    <span class="kd">func</span> <span class="nf">makeBody</span><span class="p">(</span><span class="nv">configuration</span><span class="p">:</span> <span class="kt">Configuration</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">configuration</span><span class="o">.</span><span class="n">state</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">loading</span><span class="p">:</span>
            <span class="kt">PurpleButton</span><span class="p">(</span><span class="nv">text</span><span class="p">:</span> <span class="n">buttonText</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">redacted</span><span class="p">(</span><span class="nv">reason</span><span class="p">:</span> <span class="o">.</span><span class="n">placeholder</span><span class="p">)</span>

        <span class="k">case</span> <span class="o">.</span><span class="nv">success</span><span class="p">:</span>
            <span class="kt">Button</span> <span class="p">{</span>
                <span class="n">configuration</span><span class="o">.</span><span class="nf">purchase</span><span class="p">()</span>
            <span class="p">}</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
                <span class="kt">PurpleButton</span><span class="p">(</span><span class="nv">text</span><span class="p">:</span> <span class="n">buttonText</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">buttonStyle</span><span class="p">(</span><span class="o">.</span><span class="n">plain</span><span class="p">)</span>

        <span class="k">case</span> <span class="o">.</span><span class="nf">failure</span><span class="p">(</span><span class="k">let</span> <span class="nv">error</span><span class="p">):</span>
            <span class="k">let</span> <span class="nv">_</span> <span class="o">=</span> <span class="kt">Logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="p">)</span>
            <span class="kt">PurpleButton</span><span class="p">(</span><span class="nv">text</span><span class="p">:</span> <span class="s">"Sorry, an error occurred."</span><span class="p">,</span> <span class="nv">error</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>

        <span class="k">case</span> <span class="o">.</span><span class="nv">unavailable</span><span class="p">:</span>
            <span class="kt">PurpleButton</span><span class="p">(</span><span class="nv">text</span><span class="p">:</span> <span class="s">"Sorry, an error occurred."</span><span class="p">,</span> <span class="nv">error</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>

        <span class="kd">@unknown</span> <span class="k">default</span><span class="p">:</span>
            <span class="kt">PurpleButton</span><span class="p">(</span><span class="nv">text</span><span class="p">:</span> <span class="s">"Sorry, an error occurred."</span><span class="p">,</span> <span class="nv">error</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="plan-selection-view">Plan Selection View</h3>

<p>Let users choose between plans:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@MainActor</span>
<span class="kd">struct</span> <span class="kt">PaywallPlansView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">selected</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">yearly</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">showFamilyPlans</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">VStack</span> <span class="p">{</span>
            <span class="kt">HStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">ForEach</span><span class="p">(</span><span class="n">plans</span><span class="p">,</span> <span class="nv">id</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">plan</span> <span class="k">in</span>
                    <span class="kt">ProductView</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="n">plan</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">productViewStyle</span><span class="p">(</span><span class="kt">SelectablePlanStyle</span><span class="p">(</span><span class="nv">selected</span><span class="p">:</span> <span class="err">$</span><span class="n">selected</span><span class="p">))</span>
                        <span class="o">.</span><span class="nf">tag</span><span class="p">(</span><span class="n">plan</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>

            <span class="kt">Toggle</span><span class="p">(</span><span class="s">"Add Family Sharing"</span><span class="p">,</span> <span class="nv">isOn</span><span class="p">:</span> <span class="err">$</span><span class="n">showFamilyPlans</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">showFamilyPlans</span><span class="p">)</span> <span class="p">{</span>
                    <span class="n">selected</span> <span class="o">=</span> <span class="n">showFamilyPlans</span>
                        <span class="p">?</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="nv">yearly_family</span>
                        <span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">yearly</span>
                <span class="p">}</span>

            <span class="kt">ProductView</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="n">selected</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">productViewStyle</span><span class="p">(</span><span class="kt">TrialButton</span><span class="p">(</span><span class="nv">buttonText</span><span class="p">:</span> <span class="s">"Continue"</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">plans</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="n">showFamilyPlans</span>
            <span class="p">?</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="nv">paywallFamily</span>
            <span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="n">paywallIndividual</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="selectable-plan-style">Selectable Plan Style</h3>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">SelectablePlanStyle</span><span class="p">:</span> <span class="kt">ProductViewStyle</span> <span class="p">{</span>
    <span class="kd">@Binding</span> <span class="k">var</span> <span class="nv">selected</span><span class="p">:</span> <span class="kt">String</span>

    <span class="kd">func</span> <span class="nf">makeBody</span><span class="p">(</span><span class="nv">configuration</span><span class="p">:</span> <span class="kt">Configuration</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">configuration</span><span class="o">.</span><span class="n">state</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">loading</span><span class="p">:</span>
            <span class="kt">PlanCard</span><span class="p">(</span><span class="nv">selected</span><span class="p">:</span> <span class="err">$</span><span class="n">selected</span><span class="p">,</span> <span class="nv">id</span><span class="p">:</span> <span class="s">""</span><span class="p">,</span> <span class="nv">title</span><span class="p">:</span> <span class="s">"Loading"</span><span class="p">,</span> <span class="nv">price</span><span class="p">:</span> <span class="s">"..."</span><span class="p">)</span>

        <span class="k">case</span> <span class="o">.</span><span class="nf">success</span><span class="p">(</span><span class="k">let</span> <span class="nv">product</span><span class="p">):</span>
            <span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">selected</span> <span class="o">=</span> <span class="n">product</span><span class="o">.</span><span class="n">id</span> <span class="p">})</span> <span class="p">{</span>
                <span class="kt">PlanCard</span><span class="p">(</span>
                    <span class="nv">selected</span><span class="p">:</span> <span class="err">$</span><span class="n">selected</span><span class="p">,</span>
                    <span class="nv">id</span><span class="p">:</span> <span class="n">product</span><span class="o">.</span><span class="n">id</span><span class="p">,</span>
                    <span class="nv">title</span><span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="nf">planTitle</span><span class="p">(</span><span class="n">product</span><span class="o">.</span><span class="n">id</span><span class="p">),</span>
                    <span class="nv">price</span><span class="p">:</span> <span class="n">product</span><span class="o">.</span><span class="n">displayPrice</span><span class="p">,</span>
                    <span class="nv">badgeText</span><span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="kt">Plan</span><span class="o">.</span><span class="nf">isYearly</span><span class="p">(</span><span class="n">product</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
                        <span class="p">?</span> <span class="s">"Best deal"</span>
                        <span class="p">:</span> <span class="kc">nil</span>
                <span class="p">)</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">buttonStyle</span><span class="p">(</span><span class="o">.</span><span class="n">plain</span><span class="p">)</span>

        <span class="k">case</span> <span class="o">.</span><span class="n">failure</span><span class="p">,</span> <span class="o">.</span><span class="nv">unavailable</span><span class="p">:</span>
            <span class="kt">EmptyView</span><span class="p">()</span>

        <span class="kd">@unknown</span> <span class="k">default</span><span class="p">:</span>
            <span class="kt">EmptyView</span><span class="p">()</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="transaction-history-ui">Transaction History UI</h2>

<p>Show users their complete purchase history:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@MainActor</span>
<span class="kd">struct</span> <span class="kt">TransactionsView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@ObservedObject</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">storeManager</span> <span class="o">=</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="n">shared</span>

    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">selectedTransaction</span><span class="p">:</span> <span class="kt">TransactionRecord</span><span class="p">?</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">List</span> <span class="p">{</span>
            <span class="kt">Section</span><span class="p">(</span><span class="s">"Active (</span><span class="se">\(</span><span class="n">storeManager</span><span class="o">.</span><span class="n">activeTransactions</span><span class="o">.</span><span class="n">count</span><span class="se">)</span><span class="s">)"</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">ForEach</span><span class="p">(</span><span class="n">storeManager</span><span class="o">.</span><span class="n">activeTransactions</span><span class="o">.</span><span class="nf">reversed</span><span class="p">(),</span> <span class="nv">id</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="p">{</span> <span class="n">tx</span> <span class="k">in</span>
                    <span class="kt">TransactionRowView</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="n">tx</span><span class="p">)</span>
                        <span class="o">.</span><span class="n">onTapGesture</span> <span class="p">{</span> <span class="n">selectedTransaction</span> <span class="o">=</span> <span class="n">tx</span> <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>

            <span class="kt">Section</span><span class="p">(</span><span class="s">"Inactive (</span><span class="se">\(</span><span class="n">storeManager</span><span class="o">.</span><span class="n">inActiveTransactions</span><span class="o">.</span><span class="n">count</span><span class="se">)</span><span class="s">)"</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">ForEach</span><span class="p">(</span><span class="n">storeManager</span><span class="o">.</span><span class="n">inActiveTransactions</span><span class="o">.</span><span class="nf">reversed</span><span class="p">(),</span> <span class="nv">id</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="p">{</span> <span class="n">tx</span> <span class="k">in</span>
                    <span class="kt">TransactionRowView</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="n">tx</span><span class="p">)</span>
                        <span class="o">.</span><span class="n">onTapGesture</span> <span class="p">{</span> <span class="n">selectedTransaction</span> <span class="o">=</span> <span class="n">tx</span> <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">navigationTitle</span><span class="p">(</span><span class="s">"Purchase History"</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">sheet</span><span class="p">(</span><span class="nv">item</span><span class="p">:</span> <span class="err">$</span><span class="n">selectedTransaction</span><span class="p">)</span> <span class="p">{</span> <span class="n">transaction</span> <span class="k">in</span>
            <span class="kt">TransactionDetailView</span><span class="p">(</span><span class="nv">transaction</span><span class="p">:</span> <span class="n">transaction</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="transaction-detail-with-refund-request">Transaction Detail with Refund Request</h3>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">TransactionDetailView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">transaction</span><span class="p">:</span> <span class="kt">TransactionRecord</span>

    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">refundRequestSheetIsPresented</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">List</span> <span class="p">{</span>
            <span class="kt">Section</span><span class="p">(</span><span class="s">"Status"</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Status"</span><span class="p">,</span>
                         <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">active</span> <span class="p">?</span> <span class="s">"Active"</span> <span class="p">:</span> <span class="s">"Inactive"</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="kt">Section</span><span class="p">(</span><span class="s">"Transaction Details"</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Product"</span><span class="p">,</span>
                         <span class="nv">value</span><span class="p">:</span> <span class="kt">StoreManager</span><span class="o">.</span><span class="nf">planTitle</span><span class="p">(</span><span class="n">transaction</span><span class="o">.</span><span class="n">productID</span><span class="p">))</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Type"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">productType</span><span class="p">)</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"ID"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">id</span><span class="p">?</span><span class="o">.</span><span class="n">description</span><span class="p">)</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Environment"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">environment</span><span class="p">)</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Price"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">formattedPrice</span><span class="p">)</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Will Auto Renew"</span><span class="p">,</span>
                         <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">willAutoRenew</span><span class="p">?</span><span class="o">.</span><span class="n">description</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="kt">Section</span><span class="p">(</span><span class="s">"Dates"</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Purchase Date"</span><span class="p">,</span>
                         <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">purchaseDate</span><span class="p">?</span><span class="o">.</span><span class="nf">formatted</span><span class="p">())</span>
                <span class="k">if</span> <span class="k">let</span> <span class="nv">expiration</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">expirationDate</span> <span class="p">{</span>
                    <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Expiration"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">expiration</span><span class="o">.</span><span class="nf">formatted</span><span class="p">())</span>
                <span class="p">}</span>
                <span class="k">if</span> <span class="k">let</span> <span class="nv">revocation</span> <span class="o">=</span> <span class="n">transaction</span><span class="o">.</span><span class="n">revocationDate</span> <span class="p">{</span>
                    <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Revoked"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">revocation</span><span class="o">.</span><span class="nf">formatted</span><span class="p">())</span>
                    <span class="kt">DetailRow</span><span class="p">(</span><span class="nv">title</span><span class="p">:</span> <span class="s">"Reason"</span><span class="p">,</span>
                             <span class="nv">value</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">revocationReasonString</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>

            <span class="kt">Section</span><span class="p">(</span><span class="s">"Manage"</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">Button</span><span class="p">(</span><span class="s">"Request Refund"</span><span class="p">)</span> <span class="p">{</span>
                    <span class="n">refundRequestSheetIsPresented</span> <span class="o">=</span> <span class="kc">true</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">refundRequestSheet</span><span class="p">(</span>
            <span class="nv">for</span><span class="p">:</span> <span class="n">transaction</span><span class="o">.</span><span class="n">id</span> <span class="p">??</span> <span class="mi">0</span><span class="p">,</span>
            <span class="nv">isPresented</span><span class="p">:</span> <span class="err">$</span><span class="n">refundRequestSheetIsPresented</span>
        <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="initialization">Initialization</h2>

<p>Activate StoreService during app launch:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@main</span>
<span class="kd">struct</span> <span class="kt">HelloWeatherApp</span><span class="p">:</span> <span class="kt">App</span> <span class="p">{</span>
    <span class="nf">init</span><span class="p">()</span> <span class="p">{</span>
        <span class="kt">AppMonitor</span><span class="o">.</span><span class="nf">activate</span><span class="p">()</span>  <span class="c1">// Calls StoreService.shared.activate()</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">Scene</span> <span class="p">{</span>
        <span class="kt">WindowGroup</span> <span class="p">{</span>
            <span class="kt">ContentView</span><span class="p">()</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li>
    <p><strong>Finish transactions immediately</strong> - Call <code class="language-plaintext highlighter-rouge">transaction.finish()</code> right after verification. The App Store holds unfinished transactions until you do.</p>
  </li>
  <li>
    <p><strong>Guard against false downgrades</strong> - Don’t revoke access until <code class="language-plaintext highlighter-rouge">hasUpdatedCurrentEntitlements</code> is true. On cold launch, the persisted <code class="language-plaintext highlighter-rouge">paid</code> flag might be stale.</p>
  </li>
  <li>
    <p><strong>Monitor both streams</strong> - <code class="language-plaintext highlighter-rouge">Transaction.updates</code> catches purchases, but <code class="language-plaintext highlighter-rouge">SubscriptionInfo.Status.updates</code> catches renewal state changes.</p>
  </li>
  <li>
    <p><strong>Persist everything</strong> - Store the full TransactionRecord, not just the product ID. You need expiration dates, revocation info, and renewal state for proper UI.</p>
  </li>
  <li>
    <p><strong>Pre-fetch products</strong> - Calling <code class="language-plaintext highlighter-rouge">Product.products(for:)</code> early avoids paywall loading delays.</p>
  </li>
  <li>
    <p><strong>Use app groups</strong> - Store transactions in shared UserDefaults so widgets and watch apps can check entitlements.</p>
  </li>
  <li>
    <p><strong>Handle verification failures gracefully</strong> - Log them, but don’t crash. Could be a jailbroken device or network corruption.</p>
  </li>
  <li>
    <p><strong>Test sandbox thoroughly</strong> - Use StoreKit Configuration files for unit tests, and test account sandboxes for integration testing.</p>
  </li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt:</strong> “review ~/Code/helloweather/ios and create a post about StoreKit 2 with extensive examples, this one a bit longer than others with more clear code examples. don’t hide anything, since this is standard functionality I want to share. review this implementation guide and implementation example for some work we did a few months back that may be a great starting point. create a pr and save this prompt as always, but trim the following markdown I’m pasting since it’d be duplicative…”</p>

<p>Generated by Claude using the blog-post-generator skill. Based on production code from Hello Weather’s StoreKit 2 implementation handling subscriptions, lifetime purchases, and family sharing.</p>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="storekit" /><category term="subscriptions" /><category term="in-app-purchase" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Privacy-First Crash Reporting</title><link href="https://trevorturk.github.io/privacy-first-crash-reporting/" rel="alternate" type="text/html" title="Privacy-First Crash Reporting" /><published>2026-03-04T14:00:00+00:00</published><updated>2026-03-04T14:00:00+00:00</updated><id>https://trevorturk.github.io/privacy-first-crash-reporting</id><content type="html" xml:base="https://trevorturk.github.io/privacy-first-crash-reporting/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Crash reporting SDKs want to help you. They’ll collect performance metrics, user sessions, network requests, breadcrumbs, and interaction traces. Most of this ships enabled by default.</p>

<p>For a privacy-focused app, this is a problem. You want crash reports. You don’t want to accidentally ship a user analytics platform.</p>

<h2 id="the-philosophy">The Philosophy</h2>

<p><strong>Crashes only. Nothing else.</strong></p>

<p>Collect:</p>
<ul>
  <li>Crash stack traces</li>
  <li>Device model and OS version (non-PII context)</li>
</ul>

<p>Don’t collect:</p>
<ul>
  <li>Sessions or performance telemetry</li>
  <li>User interactions or breadcrumbs</li>
  <li>Network requests or timing</li>
  <li>App hangs or diagnostic reports</li>
  <li>Any form of analytics or metrics</li>
</ul>

<h2 id="the-challenge-sdk-updates">The Challenge: SDK Updates</h2>

<p>Crash reporting SDKs evolve. New features get added. Some get enabled by default. Your carefully configured privacy settings can break with a single dependency update.</p>

<p><strong>The trap:</strong> Disabling high-level features doesn’t always disable underlying collection mechanisms.</p>

<p>For example, turning off <code class="language-plaintext highlighter-rouge">enableAutoPerformanceTracing</code> might not disable <code class="language-plaintext highlighter-rouge">enableDataSwizzling</code> - the infrastructure that makes performance tracing possible is still running, just not reporting.</p>

<h2 id="evaluation-protocol-for-sdk-updates">Evaluation Protocol for SDK Updates</h2>

<p>Before updating your crash reporting SDK:</p>

<h3 id="1-check-the-changelog-for-defaults">1. Check the changelog for defaults</h3>

<p>Look for phrases like “enabled by default”, “now automatically”, or “improved telemetry”. These are red flags that require investigation.</p>

<h3 id="2-audit-mechanisms-not-just-feature-flags">2. Audit mechanisms, not just feature flags</h3>

<p>Don’t trust that disabling a feature disables its infrastructure. Search the SDK source for:</p>
<ul>
  <li>Swizzling or method interception</li>
  <li>Timer or observer registration</li>
  <li>Network monitoring hooks</li>
  <li>File system observers</li>
</ul>

<p>If the mechanism is active, data is being collected somewhere - even if it’s not being sent yet.</p>

<h3 id="3-watch-for-these-red-flags">3. Watch for these red flags</h3>

<p>Scrutinize or explicitly disable anything involving:</p>

<ul>
  <li>Performance monitoring / tracing</li>
  <li>User interaction tracking</li>
  <li>Session replay or recording</li>
  <li>Diagnostic reports / MetricKit integration</li>
  <li>Network request monitoring</li>
  <li>Breadcrumb collection</li>
  <li>Any form of analytics or metrics</li>
  <li>“Improved crash context” (often means more data collection)</li>
</ul>

<h3 id="4-default-to-off">4. Default to off</h3>

<p>If you’re uncertain whether a feature collects user data, disable it. You can always enable it later if needed. You can’t un-collect data that’s already been sent.</p>

<h2 id="configuration-example">Configuration Example</h2>

<p>Here’s how we configure Sentry for iOS - the same principles apply to any crash reporting SDK:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">SentrySDK</span><span class="o">.</span><span class="n">start</span> <span class="p">{</span> <span class="n">options</span> <span class="k">in</span>
    <span class="n">options</span><span class="o">.</span><span class="n">dsn</span> <span class="o">=</span> <span class="s">"your-dsn"</span>

    <span class="c1">// Core crash reporting only</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableCrashHandler</span> <span class="o">=</span> <span class="kc">true</span>

    <span class="c1">// Disable everything else explicitly</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableAutoPerformanceTracing</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableUIViewControllerTracing</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableNetworkTracking</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableFileIOTracing</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableCoreDataTracing</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableSwizzling</span> <span class="o">=</span> <span class="kc">false</span>  <span class="c1">// Critical: disables the mechanism</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableAutoBreadcrumbTracking</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableNetworkBreadcrumbs</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">attachScreenshot</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">attachViewHierarchy</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableMetricKit</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableTimeToFullDisplayTracing</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="c1">// No session tracking</span>
    <span class="n">options</span><span class="o">.</span><span class="n">enableAutoSessionTracking</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="n">options</span><span class="o">.</span><span class="n">sessionTrackingIntervalMillis</span> <span class="o">=</span> <span class="mi">0</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The key insight: we disable <code class="language-plaintext highlighter-rouge">enableSwizzling</code> entirely. This is the mechanism that powers many features. Disabling it at the infrastructure level is more reliable than disabling individual features that depend on it.</p>

<h2 id="verification">Verification</h2>

<p>Configuration isn’t enough. Verify that nothing extra ships:</p>

<ol>
  <li><strong>Build in Release mode</strong> - Debug builds may behave differently</li>
  <li><strong>Run on a real device</strong> - Simulators may skip certain code paths</li>
  <li><strong>Trigger a test crash</strong> - Confirm it appears in your dashboard</li>
  <li><strong>Check for other events</strong> - Confirm NO sessions, hangs, breadcrumbs, or performance data appear</li>
</ol>

<p>If anything unexpected shows up, investigate which setting is responsible and disable it.</p>

<h2 id="platform-considerations">Platform Considerations</h2>

<p>If your app runs on multiple platforms (iOS, watchOS, widgets), ensure your crash reporting configuration works everywhere:</p>

<ul>
  <li>Avoid UIKit-specific options on watchOS</li>
  <li>Test widgets separately - they have different lifecycle</li>
  <li>Use a shared configuration helper to prevent drift</li>
</ul>

<h2 id="results">Results</h2>

<p>With this approach:</p>
<ul>
  <li>Crash reports arrive with useful stack traces and device context</li>
  <li>No user behavior data is collected</li>
  <li>SDK updates require review but don’t silently expand data collection</li>
  <li>Users can trust that “crash reports only” means exactly that</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Audit mechanisms, not features</strong> - Disabling a feature doesn’t disable its infrastructure</li>
  <li><strong>Default to off</strong> - Enable features deliberately, not by SDK default</li>
  <li><strong>Verify in production builds</strong> - Debug builds may behave differently</li>
  <li><strong>Review every SDK update</strong> - New defaults can silently expand collection</li>
  <li><strong>Document your philosophy</strong> - Future maintainers need to know why settings are disabled</li>
</ul>

<hr />

<h2 id="how-this-post-was-made">How This Post Was Made</h2>

<p><strong>Prompt:</strong> “let’s write (one or more) posts about the skills we have in helloweather web and ios. I’m thinking perhaps one about sentry in the ios repo, where we document how we want to maintain privacy and beware of new settings that might be enabled by default, to ensure we only do the minimal crash reporting and respect privacy.”</p>

<p>Generated by Claude (Opus 4.5) using the blog-post-generator skill. Based on the iOS Sentry skill from helloweather/ios, generalized to apply to any crash reporting SDK.</p>]]></content><author><name>Trevor Turk</name></author><category term="ios" /><category term="privacy" /><category term="sentry" /><category term="mobile" /><summary type="html"><![CDATA[The Problem]]></summary></entry></feed>