<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: GoodBarber</title>
    <description>The latest articles on DEV Community by GoodBarber (goodbarber).</description>
    <link>https://dev.to/goodbarber</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14004%2F83b22df0-0fa0-4978-8d15-833839b116e9.png</url>
      <title>DEV Community: GoodBarber</title>
      <link>https://dev.to/goodbarber</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/goodbarber"/>
    <language>en</language>
    <item>
      <title>What git never saw in our referral release</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 12 Nov 2026 13:00:29 +0000</pubDate>
      <link>https://dev.to/goodbarber/what-git-never-saw-in-our-referral-release-444e</link>
      <guid>https://dev.to/goodbarber/what-git-never-saw-in-our-referral-release-444e</guid>
      <description>&lt;p&gt;On the morning of October 23, I ran &lt;code&gt;git status&lt;/code&gt; on our shared test VM and found two modified files I hadn't touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;On branch PILOW_B107
Your branch is up to date with 'origin/PILOW_B107'.
Changes not staged for commit:
  modified:   common/lib/Goodbarber/Referral/Referral.php
  modified:   common/models/CommandeRemise.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked Claude Code, which was writing the feature with me, whether that was its doing. It was: a first fix, posted straight onto the VM without a commit, so that my test payment could go through.&lt;/p&gt;

&lt;p&gt;Some context. I run backend engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and that afternoon we shipped a referral program in our back office. The back office can only be tested end to end on that VM, which other people use too. Git saw the code. A good part of the release happened elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Files on the VM
&lt;/h2&gt;

&lt;p&gt;That morning, changes went onto the VM several times before they went into a commit: the layout of the code's Copy button, the first fix for a guard that blocked my test payment, the version that replaced it, and two fixes to the validator and the cron. Each time, it let me test the change before anyone committed it.&lt;/p&gt;

&lt;p&gt;One of those versions went on while my payment was in progress. Claude wrote the new file next to the old one, linted it, then renamed it over the original, so that the request in flight couldn't load a half-written file.&lt;/p&gt;

&lt;p&gt;After each commit and push, Claude realigned the VM. It compared the fingerprint of each modified file on the VM with the committed version, staged it, then fast-forwarded the VM's checkout. When the fingerprints match, the fast-forward rewrites nothing. Once, it didn't go through: restoring the files triggered the repository's Git LFS hook, which failed because the container has no git-lfs. The VM served the old page for a few seconds, and the fast-forward ran again without hooks. The commit touched no LFS file.&lt;/p&gt;

&lt;p&gt;The VM was not always aligned. After a commit that only changed a SQL file of texts, it stayed one commit behind on purpose, because those texts had already been updated by hand in the dev database. And when master had to be merged into the branch, the merged files were linted in a temporary directory of the container, without touching the VM's checkout: 37 files, 22 from the merge and 15 from the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data in the test database
&lt;/h2&gt;

&lt;p&gt;Testing a payment writes orders, invoices, vouchers, credits and payment traces for real dev apps. Before any change, a script took a photo of every row of the test apps in every table that has an &lt;code&gt;id_webzine&lt;/code&gt; column, 421 tables, plus their orders and invoices with their lines. The apps were then prepared in one transaction, which rolled back if an app wasn't in the expected state.&lt;/p&gt;

&lt;p&gt;When the test payment wouldn't go through, I suggested faking the invoice row. Claude used the code path that creates an invoice from an order instead, so the voucher's use switched to paid exactly as after a real payment. Then it moved the settlement date back 16 days to make the cron decide.&lt;/p&gt;

&lt;p&gt;At the end, the restore script put the three test apps back, and a new comparison found zero differences with each photo. The restore also removed what the payment flow had written elsewhere: payment-provider traces, the billing address I had typed, short links, login traces. The photos don't cover what isn't attached to an app, such as consumed sequence numbers and global log lines. They are still on the VM, about 70 MB with the scripts, for the next test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Texts in the database
&lt;/h2&gt;

&lt;p&gt;The referral page's texts live in a table of the back office's database, in twenty languages. The repository has a SQL file that inserts them, 340 rows, and skips any row that already exists. That makes it safe to replay, and useless for changing a text that production already has.&lt;/p&gt;

&lt;p&gt;The texts changed four times between October 23 and 26. The insert file in the repository got the new texts, for databases that don't have them yet. Production needed an &lt;code&gt;UPDATE&lt;/code&gt; script of 20 or 40 lines, tested first on a database configured like production, with &lt;code&gt;standard_conforming_strings = off&lt;/code&gt;. I copied each script with &lt;code&gt;pbcopy&lt;/code&gt;, pasted it into psql on production, then flushed the texts' cache.&lt;/p&gt;

&lt;p&gt;Those &lt;code&gt;UPDATE&lt;/code&gt; scripts lived in Claude's scratchpad under &lt;code&gt;/tmp&lt;/code&gt;. One of its messages warned that a reboot of my Mac would erase the next one, and that it would regenerate it from the insert file. That was a choice. The repository already has a precedent for versioning such update scripts next to their insert file. For this branch, my rule was code only: the feature's documentation was removed with &lt;code&gt;git rm&lt;/code&gt; before production, and the update scripts never went in.&lt;/p&gt;

&lt;p&gt;Production's texts have been changed four times by scripts that exist in no repository.&lt;/p&gt;

&lt;p&gt;How do you know, on a shared test machine, which files on disk match a commit?&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>testing</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Our customers had already told us what Siri should say</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 10 Nov 2026 11:07:45 +0000</pubDate>
      <link>https://dev.to/goodbarber/our-customers-had-already-told-us-what-siri-should-say-30b5</link>
      <guid>https://dev.to/goodbarber/our-customers-had-already-told-us-what-siri-should-say-30b5</guid>
      <description>&lt;p&gt;Thousands of apps run on our engine: a radio station, a sports club, a local paper, a pastry shop, each one different. A feature is written once, in the engine, and an app gets it at its next submission to the store, if its owner wants it. That's the point of a platform, and it's the design constraint behind everything that follows: whatever we build has to make sense in apps we will never look at one by one.&lt;/p&gt;

&lt;p&gt;What an assistant can do in an app, we &lt;a href="https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6"&gt;define the way we define a feature&lt;/a&gt;: once, in the engine, for every app that has the feature. Apple calls these things &lt;a href="https://developer.apple.com/documentation/appintents" rel="noopener noreferrer"&gt;intents&lt;/a&gt;: an intent is what someone means to do in an app, put in a form an assistant can act on. Exposing an app to an assistant means going through what the app can do and deciding, for each thing, whether an assistant can reach it, under what name, and with what answer. A developer who does this for their own app knows its content, its sections and its users, and decides for that one app. Writing an intent is quick work now. The time goes into knowing what it should cover, for all of our apps at once, including apps that don't exist yet, without opening any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Work we can't ask our customers to do
&lt;/h2&gt;

&lt;p&gt;The obvious way out is to ask the owner, the customer who built the app. A form with a few questions: which sections may Siri answer from, what should it call them, what counts as news in your app. We decided against it, for reasons that hold for every intent we will ever add.&lt;/p&gt;

&lt;p&gt;An owner builds an app to publish their content or run their organisation, not to set up an assistant. The assistant is our idea; the work it creates is ours.&lt;/p&gt;

&lt;p&gt;The apps are already in the stores. An intent that depends on a form is empty in every app until its owner fills it in. Whatever we ship has to work in each app as it is, the day it gets the update.&lt;/p&gt;

&lt;p&gt;And we would have to write the questions in advance, the same ones for the radio and the pastry shop, without knowing which matter where. We would be asking each owner to describe their app to us. They already have, once, by building it.&lt;/p&gt;

&lt;p&gt;That's the premise we work from: deciding what an assistant can do in an app is our work, and the owner should never have to do it a second time. A form could have been optional, defaults read from the structure and a setting on top; we didn't build that either. A second place where the app is described drifts from the first, and someone has to keep them in step. Whether their app gets the update is their call, as with any store submission; what it answers once it has it is what this piece is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structure is already a description
&lt;/h2&gt;

&lt;p&gt;An app takes shape in our builder one choice at a time: dozens of choices, and its owner makes none of them at random. Which sections they add, and of which kind: a radio has a live stream and podcasts, a club has a calendar and a members' area, a shop has a catalogue and a loyalty card. Which sections go on the home screen, which stay in the menu, and in what order. How many items each widget on the home screen shows. The titles they give their sections and widgets. Which sections only members can open. Across our paying customers' live iOS apps, the median app has 16 sections and the median home screen 6 widgets. None of it was meant for an assistant, because it was made to give visual cues, not semantic ones. However, each choice is a weak signal about what counts in that app and what people come to it for, and together they are a description of the app that nobody wrote as one. A radio that puts its live stream at the top of its home screen has said what people come for. A club whose only section open to everyone is its calendar has said who the rest of the app is for. The intents are in there. Our work is to read them out.&lt;/p&gt;

&lt;p&gt;What we read today, and what each reading gives the assistant:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the owner chose&lt;/th&gt;
&lt;th&gt;What it tells us&lt;/th&gt;
&lt;th&gt;What the assistant does with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Which sections exist, and of which kind&lt;/td&gt;
&lt;td&gt;What the app does: publish, stream, sell, gather members&lt;/td&gt;
&lt;td&gt;Only what exists is reachable; nothing is invented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which sections sit on the home screen&lt;/td&gt;
&lt;td&gt;What people come to the app for&lt;/td&gt;
&lt;td&gt;Where "what's new" and "what's on" read from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How many items each widget shows&lt;/td&gt;
&lt;td&gt;How much room the owner gives each one&lt;/td&gt;
&lt;td&gt;How many items each one puts forward; the answer keeps the newest five&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The order of the menu&lt;/td&gt;
&lt;td&gt;The owner's hierarchy&lt;/td&gt;
&lt;td&gt;The order of the sections Siri offers when it has to ask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The titles of sections and widgets&lt;/td&gt;
&lt;td&gt;The owner's vocabulary&lt;/td&gt;
&lt;td&gt;The words Siri uses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which sections only members can open, and what subscribers pay for&lt;/td&gt;
&lt;td&gt;Who the app is for&lt;/td&gt;
&lt;td&gt;What is listed as in the app, and what carries a lock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We don't have a method that covers everything an app does, not yet. The questions below are what we have, and for each of them we read the structure instead of asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Siri answers, and where it reads
&lt;/h2&gt;

&lt;p&gt;We started in September 2026 with Siri and with content: what's new on an app, then, in October, the latest article and the latest video in one of its sections. Three questions about events were announced in November and reach apps with their next store update: what's on today, what's coming up, what's on this weekend. They read three places in an app: the content widgets on the home screen, the menu, and the Events widgets on the home screen. &lt;a href="https://www.goodbarber.com/blog/siri-actions-ask-your-goodbarber-ios-app-for-its-latest-article-or-video-a1630/" rel="noopener noreferrer"&gt;The owner's side is on our blog&lt;/a&gt;. You can hear it yourself: install GoodBarber News, our own app, and ask Siri "What's new on GB News?"; GB News is the name under its icon, and the one Siri listens for. On a phone with Siri AI, Apple's new Siri, variations like "GoodBarber News" are understood just as well.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The question&lt;/th&gt;
&lt;th&gt;Where it reads&lt;/th&gt;
&lt;th&gt;The owner's choices it follows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What's new&lt;/td&gt;
&lt;td&gt;The content widgets on the home screen&lt;/td&gt;
&lt;td&gt;Which widgets are there, how many items each shows, what they're called&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The latest article, the latest video&lt;/td&gt;
&lt;td&gt;The menu&lt;/td&gt;
&lt;td&gt;Which sections exist, in what order, under what names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What's on today, coming up, this weekend&lt;/td&gt;
&lt;td&gt;The Events widgets on the home screen&lt;/td&gt;
&lt;td&gt;Which events are shown, and how many&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"What's new" could have been a list of everything the app has published, newest first. That design needs to know nothing about an app. It treats every app as a pile of dated items, but no owner builds an app that way. Instead, the action reads the home screen. It takes the content widgets the owner put there, articles, videos, photos, podcasts, as many items as each one shows, drops what appears twice, and puts the newest first. Siri gives each item's title and the name the owner gave the widget it came from, or its section's name when the widget has none. The date orders what the owner already chose and keeps the newest five: what gets in was the owner's choice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmrrteswf4fpb7s4qti80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmrrteswf4fpb7s4qti80.png" alt="Illustration: on the left, a phone's home screen with three widgets the owner placed there: Club news showing three items, Replays showing two, and Events, greyed out. An arrow labelled “Siri reads this screen” leads to Siri's card on the right: “The latest is Saturday's match report, in Club news.”, then five items, each tagged with the name of its widget, a lock on the two that come from Replays. A note says events stay out because their date says when they happen." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We measured the gap between the two designs. In November we replayed both on 248 of our customers' live iOS apps, the ones whose home screen we can rebuild exactly from our own data. In 197 of them, four out of five, the five items read from the home screen are not the app's five most recent. In 72, the most recent dated item in the whole app is an event that hasn't happened yet.&lt;/p&gt;

&lt;p&gt;Some signals are in what the content is, not where it sits. An event has a date too, but it means "when it happens", not "when it was published". Sorted with the rest, next June's festival would come out as the latest news. So events stay out of "what's new", and map points with them: a place isn't news either. That exclusion is a rule of ours, made once for every app, and I'd rather say so. The structure doesn't speak by itself; someone has to read it. The order is our rule too. On the home screen, the owner's order says what counts; the question asks what is recent, so the date orders the answer.&lt;/p&gt;

&lt;p&gt;"The latest article" and "the latest video" read the menu, and there we keep the owner's order. If the app has several video sections, Siri asks which one and lists them in that order, under the names the owner gave them; a section split into categories is read by its first category, and a live stream doesn't count as a video. Apps gate content in two ways, and Siri mirrors both. A section only signed-in members can open is listed as the menu lists it, and opening anything from it takes the login, as in the app; a home screen behind a login gives a signed-out visitor nothing. An item subscribers pay for is named like the rest, with a lock before its title on the card Siri shows, and opening it still takes the subscription.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypjoxe6wvuvnypc7flrv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypjoxe6wvuvnypc7flrv.png" alt="Two captures of GoodBarber News, taken the same morning. In the app: the home screen opens on an article dated November 10, 2026, “Keep listeners loyal between shows with your Mixcloud replays in your app”. Asked through Siri: the answer card reads “The latest article in Our blog is Keep listeners loyal between shows with your Mixcloud replays in your app, 2 hours ago. Do you want to open it?”, above a Yes and a No button." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The event questions read the home screen too, from the Events widgets the owner put there: as many events as each one shows, and none that is already over. The scope comes from the structure again, and the date does a different job, because an event's date says when it happens. Events on right now come first, ending soonest first, then what starts later; what's coming up stops at five. Where the structure says nothing, the rule is ours: the weekend starts at six in the evening before it, Friday or Thursday depending on which days the phone's region calls the weekend, and an event with no end time is never called "on now", because nobody told us it still is.&lt;/p&gt;

&lt;p&gt;One rule we didn't anticipate before testing on a real device: the first version was one question with a blank for the timeframe (today, this week or this weekend). In October, on an iPhone running iOS 27, Siri left "today" out of the blank and answered as if no timeframe had been named; then "this weekend" came back as "this week", and the card looked fine. A section name comes from the owner's menu, so a word that isn't on it makes Siri ask; "this week" and "this weekend" were both valid and sound alike, so Siri heard one for the other, and the answer, for the wrong week, sounded right. Each timeframe became its own question with fixed phrases, "this week" was dropped rather than shipped wrong, and fixed phrases got the right timeframe every time after that. &lt;a href="https://www.goodbarber.com/blog/siri-actions-ask-your-ios-app-what-s-on-this-weekend-a1649/" rel="noopener noreferrer"&gt;Our iOS team's write-up&lt;/a&gt; (kudos to them for the implementation!) has the whole run; what this piece adds is the decision not to ask the owner about any of it.&lt;/p&gt;

&lt;p&gt;When there is nothing to say, because nothing new is on the home screen, or no content widget at all, the answer is one sentence, and Siri stops there: "There's nothing new right now." An empty weekend names the next event there is instead.&lt;/p&gt;

&lt;p&gt;The same action gives a different answer in every app. The content is the owners', written years ago in some cases, without knowing it; the order, the cut and the sentence around it are ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the reading falls short
&lt;/h2&gt;

&lt;p&gt;The reading takes the owner's screen as it is, and that is its weak side too. A widget with no title is named after its section. A home screen whose widgets are a player, a map, a form and a calendar gives "what's new" nothing to say, because events aren't news, and Siri says so; the event questions still read that calendar. An app whose events live in a section the home screen doesn't show will tell Siri it has no events; an Events widget on the home page fixes that, and it's the owner's screen, not a setting. A widget titled "Home 1" is read as written. A home screen nobody has rearranged in years still reads fine as long as its sections are fed: the layout is old, the items are not. And the owner sees none of it in the back office. To hear what Siri says about your app, you ask it, like everyone else; to change the answer, you change the app, the same screen your users see. An owner who rearranges the home screen for a Christmas campaign has changed what Siri says without touching a setting, because there is no second place to maintain; that is the point, and the price.&lt;/p&gt;

&lt;h2&gt;
  
  
  A name that can't change
&lt;/h2&gt;

&lt;p&gt;One thing no structure can give us is the name underneath an action: the identifier Siri and Shortcuts bind to on the phone. It has to work in every app as it ships, ready to use before any owner makes it their own, and still hold later. In Apple's framework, &lt;a href="https://developer.apple.com/videos/play/wwdc2025/244/" rel="noopener noreferrer"&gt;the name of the intent becomes the unique identifier of the action&lt;/a&gt;; a developer who renames the code later can keep the old identifier with a &lt;a href="https://developer.apple.com/documentation/appintents/persistentlyidentifiable" rel="noopener noreferrer"&gt;persistent identifier&lt;/a&gt;, so what can never change is not the code's name but what a shipped identifier means. A developer with one app changes it with one update. Our apps don't rebuild together: each one gets the engine's changes at its owner's next submission, whenever that is, so whatever an identifier means has to stay true in &lt;a href="https://dev.to/goodbarber/old-apps-still-call-home-38mk"&gt;binaries built months apart&lt;/a&gt;. There's a ceiling too: an app can register &lt;a href="https://developer.apple.com/videos/play/wwdc2022/10170/" rel="noopener noreferrer"&gt;at most ten App Shortcuts&lt;/a&gt;, the actions Siri knows with no setup, so every one we ship takes one of ten slots in every app at once. For events we wrote down 32 questions people might ask, scored them on value, on how reliably Siri hears them, on whether the data is there and on cost, and kept three. Siri AI, further down, changes that ceiling: its tools are the intents themselves, marked as such in the code, with no phrase to register, and Apple's preview of November 3 puts no number on them.&lt;/p&gt;

&lt;p&gt;So the name underneath is a word in the platform's vocabulary, chosen once, before any app carries it, and never renamed, whatever an owner puts on top of it. We already had that rule for the words an app is described with: &lt;a href="https://dev.to/goodbarber/you-can-add-a-word-you-cant-change-what-one-means-3e4h"&gt;you can add a word, you can't change what one means&lt;/a&gt;. Siri added one more place where it applies, and every action we add from here will follow it.&lt;/p&gt;

&lt;p&gt;The phrases Siri listens for are shared the same way. A language is added once, for every app, before we know which app will speak it. There are eight today: English, French, Italian, German, Spanish, Portuguese, Dutch and Arabic, the phrases we wrote for App Shortcuts, the Siri that is already on iPhones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three habits, and where they'll strain
&lt;/h2&gt;

&lt;p&gt;An app doesn't only publish articles and videos. It plays a live stream, takes a form, holds a loyalty card, opens a chat between members, and for each of them the same question comes back: what should an assistant be able to do here, and how do we know without asking? Three habits came out of these questions, and I expect them to hold for the rest.&lt;/p&gt;

&lt;p&gt;The scope comes from the structure. An assistant reaches what the owner put in the app, where they put it; a member section stays a member section, and a paid item comes back with a lock, never with its content.&lt;/p&gt;

&lt;p&gt;The words come from the owner. Siri says the names the owner chose, not names we invented for every app at once. They are the owner's own: in our customers' live iOS apps, 85% of article sections carry a title no other app uses.&lt;/p&gt;

&lt;p&gt;The platform adds as little as it can: an order when one is needed, a sentence when there's nothing, and nothing the owner would have to set up for Siri alone.&lt;/p&gt;

&lt;p&gt;Take the live stream. A radio's home screen already says there is one, what it's called and that it comes first; what an assistant should do with it is in there. A form, or the pastry shop's loyalty card, is harder: they change something, which is &lt;a href="https://dev.to/goodbarber/deciding-what-an-agent-should-do-not-just-what-it-can-5h35"&gt;a different question&lt;/a&gt;, and the structure says less about what a field means than about where the form sits. That's where I expect the reading to strain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the new Siri changes
&lt;/h2&gt;

&lt;p&gt;On November 3, Apple &lt;a href="https://developer.apple.com/news/?id=gdk2umrf" rel="noopener noreferrer"&gt;opened Siri AI to every developer&lt;/a&gt;: with the iOS 27.3 betas, an app hands its intents to the new Siri, which picks actions, and skills that combine them, from what someone says, with the screen and the person's context in view. App Store submissions follow in spring 2027. For now it isn't on iPhones in the EU, where GoodBarber is based, and &lt;a href="https://www.apple.com/newsroom/2026/10/siri-ai-is-available-in-more-languages-starting-today/" rel="noopener noreferrer"&gt;its six languages&lt;/a&gt; cover four of the eight our phrases speak.&lt;/p&gt;

&lt;p&gt;Two days earlier, UX Collective ran &lt;a href="https://uxdesign.cc/context-is-the-new-interface-c16b7f6e77be" rel="noopener noreferrer"&gt;Context is the new interface&lt;/a&gt;, on App Intents and MCP and on what happens between products; my question is what each product has to decide first.&lt;/p&gt;

&lt;p&gt;The new Siri changes the way in. Language models have taught everyone to say what they mean instead of a fixed phrase, and nobody remembers a fixed phrase for an app they open twice a month; that is what makes asking an app credible.&lt;/p&gt;

&lt;p&gt;It doesn't change who reads the app. A screen doesn't say what a date means, who may open a section, or how many items a widget holds. When Siri picks the action itself from what someone says, the words it reads to choose are the ones already there: our description of the action, and the owner's names when it has to pick a section. The reading stays, and the new Siri gives it more to do. Everything here is iOS and Siri, the first assistant our apps answer to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow the choices the owner already made
&lt;/h2&gt;

&lt;p&gt;When the owner has already made a choice, our actions follow it instead of making their own. Where they never made one, we make it once, for every app, and we say which. The list today: the newest five; the date order in "what's new"; "what's coming up" stopping at five; events and map points out of it; an event with no end time never "on now"; the weekend from six the evening before; the lock on paid items; a section split into categories read by its first category; live streams kept out of "the latest video"; and the sentence when there is nothing. We keep it short on purpose.&lt;/p&gt;

&lt;p&gt;An app is how a radio, a club or a shop puts what it does in people's hands; that's what it exists for. An assistant doesn't replace it; it's another UI on the same app, and what it can do there should come from choices owners have already made, in the structure each of them has already built, across thousands of apps. Reading that structure is on us, not on them, and we're at the start of it.&lt;/p&gt;

&lt;p&gt;Before you add a settings screen for your assistant: what have your customers already told you by using the product?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>architecture</category>
      <category>ios</category>
    </item>
    <item>
      <title>Our referral reward is decided 16 days after the payment. The rows it reads can change in between.</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Fri, 06 Nov 2026 12:56:16 +0000</pubDate>
      <link>https://dev.to/goodbarber/our-referral-reward-is-decided-16-days-after-the-payment-the-rows-it-reads-can-change-in-between-3m5j</link>
      <guid>https://dev.to/goodbarber/our-referral-reward-is-decided-16-days-after-the-payment-the-rows-it-reads-can-change-in-between-3m5j</guid>
      <description>&lt;p&gt;Our referral program rewards the sponsor 16 days after the referee's payment. A cron takes that decision every morning. The referee's app has to be still active, its invoice paid without incident and without a credit note, and a few other checks have to pass. If a condition fails, the cron tries again the next day, and gives up 46 days after the payment.&lt;/p&gt;

&lt;p&gt;Some context. I run backend engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and the referral program went live on October 23. Before launch, the review found three bugs in that decision. They have the same root. A check that runs 16 days later reads the database as it is now, and the database had changed in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which date is "the payment"?
&lt;/h2&gt;

&lt;p&gt;The first version counted the 16 days from the invoice date. An invoice can be created, go unpaid, and be settled later. When we regularize an unpaid invoice, only its settlement date changes. Counted from the invoice, the delay could end only a few days after the money actually arrived.&lt;/p&gt;

&lt;p&gt;The delay and the 30-day retry window now start from the settlement date.&lt;/p&gt;

&lt;h2&gt;
  
  
  The row that was overwritten
&lt;/h2&gt;

&lt;p&gt;One of the checks looks at the details of the referee's payment. It read the app's current direct-debit mandate, and updating bank details overwrites that row. If the details changed between the payment and day 16, the check looked at values the payment never used.&lt;/p&gt;

&lt;p&gt;The payment history had the answer. Each direct-debit payment keeps the account it was taken from, next to its invoice. The check now reads that history as well as the current mandate. We tested it on an app that has a payment history and no current mandate.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Already granted", checked by existence
&lt;/h2&gt;

&lt;p&gt;Past a number of validated referrals, the sponsor earns a discount on a renewal. The cron grants it as a personal voucher with a pending use, which our renewal process attaches to the next renewal order. Before granting it, the cron checks whether that voucher already exists, so that it never grants it twice.&lt;/p&gt;

&lt;p&gt;A chain of existing code broke that check. The renewal process attaches the pending use to an unpaid renewal order. If the customer changes plan before paying, the plan change deletes that unpaid order, and the order's delete hook deletes the voucher's use with it, but not the voucher. On its next pass, the cron finds the voucher and does nothing. The reward is gone, and nothing says so.&lt;/p&gt;

&lt;p&gt;The fix leaves the existing billing code alone. A daily pass looks for tier vouchers that aren't deleted and have no use at all, pending or consumed, and gives them a pending use again, under the same per-app lock. To withdraw a reward on purpose, we mark the voucher deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it again changes nothing
&lt;/h2&gt;

&lt;p&gt;A decision that retries every day for 30 days runs many times on the same data. Each credit carries its invoice in its origin, &lt;code&gt;referral_referee:&amp;lt;invoice&amp;gt;&lt;/code&gt; or &lt;code&gt;referral_sponsor:&amp;lt;invoice&amp;gt;&lt;/code&gt;, and is only granted if that origin doesn't exist yet. The cron takes a PostgreSQL advisory lock per app, &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;, and checks again once it holds the lock. During testing, we ran it several times in a row on a validated referral: it logged "validated" again and paid nothing. We never ran two passes at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window also limits the kill switch
&lt;/h2&gt;

&lt;p&gt;Commenting out the crontab line stops every reward without a deploy, while the page and the checkout keep working. The 46 days apply there too. A referral whose payment is more than 46 days old when the cron comes back is never rewarded.&lt;/p&gt;

&lt;p&gt;What does your delayed job read: the state at the event, or the state when it runs?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>postgres</category>
      <category>php</category>
      <category>database</category>
    </item>
    <item>
      <title>Where do you put a referral program when everything it touches lives in the monolith?</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 03 Nov 2026 13:06:12 +0000</pubDate>
      <link>https://dev.to/goodbarber/where-do-you-put-a-referral-program-when-everything-it-touches-lives-in-the-monolith-4hd1</link>
      <guid>https://dev.to/goodbarber/where-do-you-put-a-referral-program-when-everything-it-touches-lives-in-the-monolith-4hd1</guid>
      <description>&lt;p&gt;On October 22, I picked up a referral program halfway through. A first version applied the referral discount by writing it straight into the order line. I refused it. I wanted the discount to go through the voucher system we already have, and the referral to stay tracked.&lt;/p&gt;

&lt;p&gt;Some context. I run backend engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and our customers pay for their apps through a back office that is a PHP monolith. A referral program has to touch the coupon field at checkout, the payment confirmation, invoices and credit notes, vouchers, the credits we give apps, and renewals, which a cron of our older CMS creates. All of that lives in the monolith. The question was where the referral itself should live.&lt;/p&gt;

&lt;p&gt;Between 14:37 and 15:55 that day, I went around five answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five places for the same code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A new table in the shared database, with its model.&lt;/strong&gt; The obvious answer, and the one I liked least. Our billing models are old, and I was afraid of adding one next to them and breaking something I wouldn't see break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key/value table we already have.&lt;/strong&gt; It stores attributes per user and per app, and already holds more than rights. The sponsor would get a &lt;code&gt;referral_code&lt;/code&gt; row, the referee a &lt;code&gt;referral&lt;/code&gt; row holding a small JSON state. No migration. What we'd lose is uniqueness in the database: it would become a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on the sponsor's row during the reservation, to be proven under concurrency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A separate service with its own database&lt;/strong&gt;, built like many of our Python services. The rules would be real constraints again, in a database of their own. But the monolith would still call it at three points: the page that shows the code, the checkout that reserves it, and an endpoint to grant rewards. Reserving a code would become a network call in the middle of the payment path. "One use only" would be split across two systems: the service reserves, the monolith applies the voucher, and if one of them fails, something has to undo the other. And it would be one more thing to deploy, host, monitor and back up, for the first version of a program.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed code, with nothing stored up front.&lt;/strong&gt; The code would carry the sponsor's app and a signature, and only its use would be stored. The code gets longer, and "which app gets the reward" becomes a computed rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voucher table we already had.&lt;/strong&gt; The sponsor's code becomes a voucher. The referee types it at checkout like any promo code, and a cron checks how the code is used and grants the rewards. That one was my call, and it's the one we shipped.&lt;/p&gt;

&lt;p&gt;On the way there, I came back once to the first option with the word KISS and a warning about the model. The voucher table removed the question, since it needed neither a new table nor a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the voucher table already knew
&lt;/h2&gt;

&lt;p&gt;Most of the rules already had a column. The maximum number of uses is a field the voucher validation already counts. "First subscription only" is a flag the checkout already enforces. The sponsor's app is the voucher's owner. Each use is a row in the usage table, which switches to paid when the invoice is created. The cron only has to read paid uses that are old enough to be validated.&lt;/p&gt;

&lt;p&gt;When I asked a colleague to review the branch, it had seven commits on top of master: 17 files, 1,654 lines added, 14 removed, and no migration. The program went live on October 23.&lt;/p&gt;

&lt;p&gt;Its rules changed three times in the following days, and its texts four. Each rule change was a constant or a cron, plus the texts in twenty languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;The voucher table was never meant to tell one kind of voucher from another. Without a new column, the only thing that marks a referral code is its shape, and other code writes into that table too. A tenant guard in our ORM also had opinions about a code that belongs to one app and is used by another. Those are a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions I still have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For a program whose rules you expect to change, would you have paid for a separate service from day one?&lt;/li&gt;
&lt;li&gt;When you can't add a column, is a code's shape an acceptable marker, or a debt you'll pay the day someone asks for custom codes?&lt;/li&gt;
&lt;li&gt;Rewards in the payment path or in a cron? We chose a cron, to limit what could break a payment.&lt;/li&gt;
&lt;li&gt;What would make you move it out of the monolith: a number of rules, a second product using it, a team that owns it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last time a small feature needed half of your monolith, where did you put it?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>postgres</category>
      <category>discuss</category>
      <category>php</category>
    </item>
    <item>
      <title>Old apps still call home</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:20:56 +0000</pubDate>
      <link>https://dev.to/goodbarber/old-apps-still-call-home-38mk</link>
      <guid>https://dev.to/goodbarber/old-apps-still-call-home-38mk</guid>
      <description>&lt;p&gt;Somewhere this morning, a phone opened an app that was built on our platform years ago. Its owner hasn't touched it in a long time. The person holding the phone never updated the app, or no update ever came, because nobody rebuilt it. The app started, asked our servers for its content, and got an answer it could read.&lt;/p&gt;

&lt;p&gt;That only works because we still answer in the format the app was built for, even though our own formats have changed since.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client you can't upgrade
&lt;/h2&gt;

&lt;p&gt;Most advice on API versioning assumes one of two things about your clients: you control them, or you can retire them. A web app ships its own front end, so a breaking change is a coordinated deploy. A public API has a deprecation calendar, a sunset header, an email to the developers. Either way, at some point the old client stops being your problem.&lt;/p&gt;

&lt;p&gt;A platform that publishes apps can do neither. The client is a binary on someone's phone. It was built on a day we didn't choose, and its next version, if there is one, comes from the store on a day we don't choose either. We don't force updates: an app that works keeps working, and nobody is asked to rebuild because our own formats changed. &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;I've written about what that promise costs when the world around an app moves&lt;/a&gt;. This piece is about what it costs when &lt;em&gt;we&lt;/em&gt; move.&lt;/p&gt;

&lt;p&gt;And we do move. The engine gets new generations, our answers change shape, newer endpoints get a cleaner design than the old ones. Meanwhile, the apps published before each of those changes are still installed, still speaking the dialect they were built with, and still expecting an answer in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dialects, side by side
&lt;/h2&gt;

&lt;p&gt;When a new generation of the engine arrived, the old one didn't move. It kept its paths, its file layout and the shape of its answers. The new generation got its own tree next to it. The same servers answer both today, and an app built against the old generation is served the old generation, for as long as it's around.&lt;/p&gt;

&lt;p&gt;A phone only gets the new generation with a new build of the app, installed from the store. Until then, it runs the old binary, and the app's answers in the old tree are still there for it: they aren't deleted, and they keep the shape that binary was built for. The new build reads its own answers, written next to them in the new shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just force the update?
&lt;/h2&gt;

&lt;p&gt;Forcing an update would move the work onto other people. An update we force is a rebuild the owner has to run, &lt;a href="https://dev.to/goodbarber/every-rejection-becomes-a-check-gbk"&gt;a submission they have to make again&lt;/a&gt;, and a download the user has to accept: two parties doing work so that we can retire one code path. Multiplied by every app on the platform, that's a lot of work on their side to save some on ours.&lt;/p&gt;

&lt;p&gt;Keeping a dialect alive is work we do once, on our side, instead of asking every owner for a rebuild. It has a cost: we keep testing every old path, and we keep maintaining a reader for every format we still serve. It's the same reasoning that keeps &lt;a href="https://dev.to/goodbarber/the-url-on-the-flyer-doesnt-know-your-app-changed-9kn"&gt;a link printed on a flyer years ago&lt;/a&gt; opening the right screen in today's version of the app.&lt;/p&gt;

&lt;p&gt;There's a limit, and it comes from outside. When a store, an operating system or a certificate retires something an old binary relies on, that app needs a rebuild, whatever our servers answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still asking
&lt;/h2&gt;

&lt;p&gt;Some of the apps calling our servers this morning haven't been rebuilt in years, and people still use them. Keeping them working means keeping their vocabulary: answering in the words they were built with, and retiring nothing while someone still depends on it.&lt;/p&gt;

&lt;p&gt;If you run an API for clients you can't upgrade, how do you decide when an old version can finally stop answering?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>mobile</category>
      <category>backend</category>
      <category>api</category>
    </item>
    <item>
      <title>Agents don't mind the brackets: can AI bring back demanding languages?</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/goodbarber/agents-dont-mind-the-brackets-can-ai-bring-back-demanding-languages-398g</link>
      <guid>https://dev.to/goodbarber/agents-dont-mind-the-brackets-can-ai-bring-back-demanding-languages-398g</guid>
      <description>&lt;p&gt;&lt;em&gt;Rust asked a lot of the people who wrote it. We're no longer the ones writing it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had this hunch from reading what people write about Rust and agents: the languages we found too demanding might come back, because we're no longer the ones paying their price.&lt;/p&gt;

&lt;p&gt;That suited me: I started out as an Objective-C developer, and I've always kept a soft spot for this neglected language. In 2015, at dotSwift, the first Swift conference in Paris, I watched &lt;a href="https://www.youtube.com/watch?v=BVFtrrXhApw&amp;amp;t=295s" rel="noopener noreferrer"&gt;Daniel Steinberg compare it to French cuisine&lt;/a&gt;, as opposed to fast food. The image stuck with me, I still prefer fine dining, and it has become a running joke with our junior developers.&lt;/p&gt;

&lt;p&gt;In October 2026, an experiment on Objective-C added weight to that hunch. Fernando Olivares, an iOS developer at 37signals, asked two agents, Claude Opus 5.5 and GPT-6.1 Sol, to build the same chat screen in Swift and in Objective-C, then measured everything on an iPhone 12 and published &lt;a href="https://github.com/notolivaresf/uikit-vs-swiftui" rel="noopener noreferrer"&gt;the code and the results&lt;/a&gt;. The agents wrote the Objective-C, and it is the leanest version, on a single measurement pass per version: 35 MB of memory at peak, against 44.5 MB for the best Swift version built with the same framework.&lt;/p&gt;

&lt;p&gt;I'm not betting on an Objective-C comeback for all that. But what the experiment shows is serious: an agent effortlessly writes a language we found tedious, and that applies first of all to Rust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price of Rust
&lt;/h2&gt;

&lt;p&gt;Rust is the language developers admire most: in the &lt;a href="https://survey.stackoverflow.co/2026/technology" rel="noopener noreferrer"&gt;2026 Stack Overflow survey&lt;/a&gt;, 67% of the people who use it want to keep using it, a score no other language reaches. Yet many admire it from a distance. Its compiler rejects anything that isn't safe, and it takes a long time to stop fighting it.&lt;/p&gt;

&lt;p&gt;Objective-C was paid for in lines to type, with brackets everywhere. Rust is paid for in compiler rejections. In both cases, the cost fell on the people writing the code. For years, we chose our languages for their comfort.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent changes
&lt;/h2&gt;

&lt;p&gt;An agent doesn't get tired. Writing a forty-character method name costs it a few tokens, not its patience. And the Rust compiler's rejections, which discourage a human, become a free check on its own work.&lt;/p&gt;

&lt;p&gt;The first measurements point the same way. When &lt;a href="https://swebench.com/multilingual" rel="noopener noreferrer"&gt;SWE-bench Multilingual&lt;/a&gt; launched, a benchmark that asks an agent to fix real issues in nine languages, Rust was the language where the agent succeeded most often, and C or C++ where it succeeded least. In July, a team showed that feeding the Rust compiler's errors to the model while it writes brings the share of code that doesn't compile down from 20.7% to 13.1% (&lt;a href="https://arxiv.org/abs/2607.13921" rel="noopener noreferrer"&gt;arXiv 2607.13921&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;When writing costs almost nothing, what's left is what the language guarantees. For Rust, that's memory safety, and the stakes are concrete: at Chromium, &lt;a href="https://www.chromium.org/Home/chromium-security/memory-safety/" rel="noopener noreferrer"&gt;around 70% of serious security bugs&lt;/a&gt; are memory safety problems.&lt;/p&gt;

&lt;p&gt;And projects are already following. A team studied GitHub projects that use coding agents: among those created after August 2025, the share of Rust projects doubles, 10.5% against 5.3% among older ones, while C and C++ decline (&lt;a href="https://arxiv.org/abs/2606.07448" rel="noopener noreferrer"&gt;Robbes et al., arXiv 2606.07448&lt;/a&gt;). Niko Matsakis, one of Rust's longtime designers, &lt;a href="https://smallcultfollowing.com/babysteps/blog/2026/10/14/agentic-dev-and-rust/" rel="noopener noreferrer"&gt;sees it too&lt;/a&gt;: in his words, Rust "happens to be a really good fit for agentic coding", and he draws ideas from it for evolving the language. I see it from another angle, that of the price of these languages, which has changed hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I see at work
&lt;/h2&gt;

&lt;p&gt;We needed Rust on a specific project: the same calculation had to give exactly the same result in the browser and on our servers. Writing two versions, one in JavaScript and one on the server side, meant accepting that they would eventually drift apart. Rust compiles to both, but it wasn't part of our stack. We're very happy with the result and the experience, but let's be honest: without agents, we wouldn't have tried.&lt;/p&gt;

&lt;p&gt;The same mechanism plays out, more modestly, in our iOS engine. Born in Objective-C in 2011, it was gradually migrated to Swift, because it was getting hard to find developers who master Objective-C. The parts we no longer evolve stayed in their original language, and we can now fix them without depending on whoever on the team can still read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further out: proof languages
&lt;/h2&gt;

&lt;p&gt;Rust isn't the most demanding language. With Lean or Dafny, you write the program, and also the mathematical proof that it does what you expect. The human price is huge: according to &lt;a href="https://martin.kleppmann.com/2025/12/08/ai-formal-verification.html" rel="noopener noreferrer"&gt;Martin Kleppmann&lt;/a&gt;, proving the 8,700 lines of C in the seL4 kernel took 20 person-years and about 200,000 lines of proof. Few people know how to write these proofs, and almost nobody does it for ordinary code.&lt;/p&gt;

&lt;p&gt;Kleppmann draws a prediction from it: AI will make formal verification mainstream. His argument is the Rust compiler's, pushed to the limit: the proof checker rejects any invalid proof and forces the agent to retry, so a made-up proof doesn't get through.&lt;/p&gt;

&lt;p&gt;The numbers partly bear him out. In a &lt;a href="https://arxiv.org/abs/2509.22908" rel="noopener noreferrer"&gt;study published in September 2025&lt;/a&gt;, the models' success rate on Dafny verification tasks had risen from 68% to 96% in a year. At the same date, they solved only 44% of the problems in Verus, the version of Rust built for proofs, and 27% in Lean. Verus is newer, and models have seen less of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;The question "which language?" had stopped being asked: we took the most comfortable one, and paid the difference elsewhere, in memory vulnerabilities or in bugs no proof had ruled out. Agents reopen the question. We can once again choose a language for what it guarantees, as long as the model knows it well enough.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/photos/9TucEPaZxok" rel="noopener noreferrer"&gt;Judith Browne&lt;/a&gt; on Unsplash.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Mathieu Poli, Head of Frontend Engineering at GoodBarber. I write about frontend engineering, product design and AI.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ai</category>
      <category>computerscience</category>
      <category>ios</category>
    </item>
    <item>
      <title>Adding tools to an MCP server that ChatGPT has already approved</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:03:14 +0000</pubDate>
      <link>https://dev.to/goodbarber/adding-tools-to-an-mcp-server-that-chatgpt-has-already-approved-2cm4</link>
      <guid>https://dev.to/goodbarber/adding-tools-to-an-mcp-server-that-chatgpt-has-already-approved-2cm4</guid>
      <description>&lt;p&gt;On October 7 we added seven tools to our public MCP server: read the booking calendars of an app, list sessions, cancel a booking, manage closing days. The server is &lt;a href="https://dev.to/goodbarber/our-mcp-server-is-now-a-chatgpt-plugin-2pjm"&gt;listed in ChatGPT's directory&lt;/a&gt; and in Claude's, and people also connect to it directly from Claude Code, Cursor and others. The question I asked before anyone wrote code: how do we add tools for the direct users without touching what ChatGPT approved?&lt;/p&gt;

&lt;p&gt;One line of context: I run engineering at GoodBarber, an app platform, and &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; lets assistants manage the apps through an authenticated connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  One server, three audiences
&lt;/h2&gt;

&lt;p&gt;The first step was a map of who sees what. I had a rough idea; the code had the exact one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;audience&lt;/th&gt;
&lt;th&gt;what it gets&lt;/th&gt;
&lt;th&gt;what a new tool does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT app&lt;/td&gt;
&lt;td&gt;a frozen list of 45 tools, the ones OpenAI reviewed&lt;/td&gt;
&lt;td&gt;absent from the list; a call to it is refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude directory&lt;/td&gt;
&lt;td&gt;the full catalog, which follows each deploy&lt;/td&gt;
&lt;td&gt;appears with no new submission; the tool names shown on the listing page are updated by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct clients&lt;/td&gt;
&lt;td&gt;the full catalog&lt;/td&gt;
&lt;td&gt;appears&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a new tool is free for the Claude directory and for direct clients, and the frozen list keeps it away from ChatGPT. That could have been the whole plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coupling no test covered
&lt;/h2&gt;

&lt;p&gt;The ChatGPT side is a facade over the same catalog. It picks the 45 tools and overrides their descriptions, plus one schema. Everything else it serves, the titles, the input schemas, the annotations that say whether a tool reads or writes, comes from the code shared with every other audience.&lt;/p&gt;

&lt;p&gt;That means a change made for the new tools, say a tweak to the helper that turns tool names into titles, or to the function that builds annotations, would also change what ChatGPT serves for tools OpenAI already reviewed. Nobody would notice: the existing test only checked the names and the order of the 45.&lt;/p&gt;

&lt;p&gt;So step zero, before any booking code, was a test that freezes everything ChatGPT sees, for several configurations the facade serves: name, title, description, input schema, annotations, compared to a committed reference file. Any difference fails CI and names the tool. Simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_chatgpt_surface_matches_reference&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;served&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;chatgpt_tools_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAMPLE_APP&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REFERENCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;served&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;served&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; changed for ChatGPT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we checked that the test can fail. The first attempt changed a field, and the test stayed green. Not because the test was wrong: that field is rewritten by an override before it reaches ChatGPT, so the change never got there. The second attempt added a field nothing overrides, and the test failed, naming the tool. A guard you haven't seen fail is a guess.&lt;/p&gt;

&lt;p&gt;A second test checks the other direction: none of the new tools reaches ChatGPT.&lt;/p&gt;

&lt;p&gt;With both in place, the booking work could touch the shared code freely. If it moved something OpenAI had approved, CI would say so before a deploy did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tool that cancels bookings
&lt;/h2&gt;

&lt;p&gt;Four of the seven tools read. The three that write are the reason this section exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating a closing day&lt;/strong&gt; in the back office cancels every booking on that day and sends each attendee a cancellation email. If the app's calendars inherit closing days from a default one, the closure applies to all of them. A person in the back office sees a confirmation dialog. A model sees a tool description, and a tool description is a suggestion.&lt;/p&gt;

&lt;p&gt;So the tool checks before it acts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it counts the bookings that the closure would cancel, across every calendar it would apply to;&lt;/li&gt;
&lt;li&gt;if there are any, it creates nothing and returns &lt;code&gt;confirmation_required&lt;/code&gt; with the count;&lt;/li&gt;
&lt;li&gt;it only goes ahead when the call carries &lt;code&gt;cancel_existing_bookings: true&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;it refuses past days;&lt;/li&gt;
&lt;li&gt;it is annotated as destructive, and its description says what happens to attendees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model can still cancel twenty bookings, and nothing stops it from setting the flag on its first call. What it can't do is cancel them by default: a call that doesn't ask for it returns the count, and the description tells the model to show that count to the user and get a yes first. That part is an instruction, not a lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting a closing day&lt;/strong&gt; makes the day bookable again if the calendar opens on it, but restores none of the bookings the closure cancelled. That one is not destructive in itself, and it's the kind of thing a model would assume: undoing the closure undoes the cancellations. It doesn't, and the description says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancelling a booking&lt;/strong&gt; uses the same call as the back-office button, with the same email to the attendee. A booking that is already cancelled comes back as "not found, nothing was changed" instead of an error, so a model that retries after a timeout, or after the attendee cancelled from their own link, doesn't report a failure that didn't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access: a permission, not a list of pilot apps
&lt;/h2&gt;

&lt;p&gt;Booking data includes attendees' names and emails, so the tools got their own permission in our API credentials service, read-only or read-write, offered only to the app type that has bookings. Existing keys don't get it; someone has to tick it.&lt;/p&gt;

&lt;p&gt;That gave us a safe deploy order with no list of pilot apps: ship the server first, with tools nobody can see because no key carries the permission; then create the permission, which is the moment they become available. One decision went the other way on purpose: new keys pre-select every service in read-only, and bookings follow the same rule as the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metadata that drifts
&lt;/h2&gt;

&lt;p&gt;The tools were live in minutes. The descriptions of the server took longer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The server card&lt;/strong&gt; builds its tool list from the catalog, so it showed the new tools right after the deploy: 157, up from 150. Its version, though, was written by hand, and it was already behind the version in our registry entry. Both moved to 1.3.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The registry entry&lt;/strong&gt; has a 100-character description. Adding "bookings" meant "mobile app" became "app". The publishing token had expired, so that took a new login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Claude directory listing&lt;/strong&gt; shows a list of tool names that is edited by hand and validated by a reviewer, one of the details I went through in &lt;a href="https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con"&gt;what each MCP directory actually checks&lt;/a&gt;. The tools themselves were already available to Claude users; the listing just didn't say so yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ChatGPT app&lt;/strong&gt; needed nothing. Nothing it serves changed, and the frozen test is what lets us say that. The day bookings should reach ChatGPT, that will be a new version of the facade and a new submission, on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the deploy, every call to the booking API ran against mocks, since only the production server is allowed to call it. The first real call came after the deploy, from a test key with only the booking permission. It worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Map your audiences from the code, not from memory.&lt;/strong&gt; ChatGPT's frozen list was the part I knew about; the shared code underneath it was the part I didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze what a reviewer approved, not what you meant to ship.&lt;/strong&gt; Names and order are what you think of. Schemas, titles and annotations are what a shared helper changes behind your back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the guard fail once.&lt;/strong&gt; Our first attempt at breaking it changed a field that never reaches ChatGPT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a model, a confirmation dialog is a refusal with a number in it.&lt;/strong&gt; Return what would happen, and require an explicit flag to make it happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate new tools with a permission.&lt;/strong&gt; It gives you a deploy where the code is live and the capability isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your MCP server is listed somewhere: what exactly did the reviewer approve, and which test would tell you it changed?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>chatgpt</category>
      <category>python</category>
    </item>
    <item>
      <title>How do you manage CORS for thousands of customer domains? Per tenant, with three answers instead of two</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:19:58 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-manage-cors-for-thousands-of-customer-domains-per-tenant-with-three-answers-instead-of-427</link>
      <guid>https://dev.to/goodbarber/how-do-you-manage-cors-for-thousands-of-customer-domains-per-tenant-with-three-answers-instead-of-427</guid>
      <description>&lt;p&gt;Our domain registry can stop answering. It can also answer that a customer's domain is no longer allowed. Both cases leave the proxy without a usable current list, but only the first gives us a reason to reuse an older one.&lt;/p&gt;

&lt;p&gt;That distinction shaped our per-tenant CORS cache. We keep valid results for 15 minutes and a fallback copy for 24 hours. An authoritative refusal erases that fallback, so the next outage cannot bring a removed domain back.&lt;/p&gt;

&lt;h2&gt;
  
  
  One domain per customer
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, the web version of each customer app has its own domain, either a platform subdomain or a custom one. We are piloting a proxy that lets widgets call third-party APIs while their private keys stay on the server. Its CORS policy has to fit that domain model, even while the service itself is limited to a small pilot.&lt;/p&gt;

&lt;p&gt;Our first static allowlist was manageable for five pilot apps. It could not follow the platform's thousands of domains as customers changed them. Allowing arbitrary origins would also defeat the restriction we wanted; PortSwigger documents that mistake in its guide to &lt;a href="https://portswigger.net/web-security/cors" rel="noopener noreferrer"&gt;CORS misconfigurations&lt;/a&gt;. We needed to resolve the list for each tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preflight does not know who is calling
&lt;/h2&gt;

&lt;p&gt;An actual API call carries a short-lived token naming the tenant. Its preflight arrives earlier, without that token: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" rel="noopener noreferrer"&gt;CORS preflight requests do not carry credentials&lt;/a&gt;. We receive an origin, a proposed method and a list of header names, so the token cannot supply the tenant for this lookup.&lt;/p&gt;

&lt;p&gt;The request path can. It contains an opaque connector handle belonging to one tenant. We resolve the handle, then the tenant's domains. Because this happens before token validation, the lookup must also tolerate requests from arbitrary callers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Origins, exactly as a browser sends them
&lt;/h2&gt;

&lt;p&gt;The registry returns a domain. A browser sends an origin, and the comparison is string equality, so the list has to be built the way a browser would write it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default port for the scheme is omitted: &lt;code&gt;:443&lt;/code&gt; for HTTPS, &lt;code&gt;:80&lt;/code&gt; for HTTP. Non-default ports are kept.&lt;/li&gt;
&lt;li&gt;A custom domain yields two origins, with and without &lt;code&gt;www&lt;/code&gt;, since customers publish both. A subdomain of ours yields exactly one.&lt;/li&gt;
&lt;li&gt;An internationalised domain is converted to punycode, which is what the browser sends.&lt;/li&gt;
&lt;li&gt;The response carries the one matching origin and &lt;code&gt;Vary: Origin&lt;/code&gt;, as MDN recommends &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin" rel="noopener noreferrer"&gt;for an explicit origin&lt;/a&gt;. Never &lt;code&gt;*&lt;/code&gt;, and &lt;code&gt;Origin: null&lt;/code&gt; never matches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A miss needs a reason
&lt;/h2&gt;

&lt;p&gt;Once the main cache entry expires, the registry lookup can produce three different outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The registry…&lt;/th&gt;
&lt;th&gt;What the proxy serves&lt;/th&gt;
&lt;th&gt;What it stores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;answers with domains&lt;/td&gt;
&lt;td&gt;those origins&lt;/td&gt;
&lt;td&gt;the list for 15 minutes, and a second copy for 24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cannot be reached, answers an error or sends garbage&lt;/td&gt;
&lt;td&gt;the 24-hour copy, if there is one&lt;/td&gt;
&lt;td&gt;the main entry again, for 60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;answers clearly that the tenant has no usable domain&lt;/td&gt;
&lt;td&gt;no CORS headers&lt;/td&gt;
&lt;td&gt;an empty list for 60 seconds, and it deletes the 24-hour copy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The outage path borrows the idea of &lt;a href="https://www.rfc-editor.org/rfc/rfc5861" rel="noopener noreferrer"&gt;&lt;code&gt;stale-if-error&lt;/code&gt;&lt;/a&gt;. Apps can keep using a previously accepted origin while the registry is unavailable. Caching that fallback decision for 60 seconds reduces repeated lookups; it does not by itself guarantee a global request-rate bound when concurrent misses occur.&lt;/p&gt;

&lt;p&gt;An explicit removal has a different meaning. If we kept its old list, a later registry outage could restore the revoked origin from the 24-hour copy. Deleting that copy makes the refusal survive the outage too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-auth path is bounded
&lt;/h2&gt;

&lt;p&gt;Before any I/O, we check the handle's shape and hash it for use in cache keys. Unknown handles are cached as unknown for 60 seconds. If the shared cache is unavailable, an in-process cache lasting 5 seconds, with at most 4,096 entries, limits repeated database work.&lt;/p&gt;

&lt;p&gt;The preflight path responds with &lt;code&gt;204&lt;/code&gt;. If neither the lookup nor its permitted fallback yields an allowed origin, the response omits CORS headers. Dependency errors therefore do not become a &lt;code&gt;500&lt;/code&gt;, and the browser cannot proceed with the cross-origin call after an unsuccessful preflight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat your cache as input
&lt;/h2&gt;

&lt;p&gt;The cache is shared infrastructure, so what comes out of it is validated like anything else that crosses a boundary. Values are JSON, never pickled objects. Every list read back is checked, item by item, against the same pattern that judged it on the way in. A value that exists but fails that check is ignored and logged as a tampering signal.&lt;/p&gt;

&lt;p&gt;Review found a mismatch here. Normalisation accepted a hostname containing an underscore, but cache validation used a stricter rule and rejected it. That value would have been repeatedly written, discarded and fetched again, leaving both caches ineffective for the tenant. Applying the same rule on write and read removed the disagreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS is not the lock
&lt;/h2&gt;

&lt;p&gt;These responses govern browser access. A client such as &lt;code&gt;curl&lt;/code&gt; does not enforce them, so actual requests still undergo token validation and the connector's permission checks. Using an older CORS list leaves those checks in place; it changes which browser origins may read the responses.&lt;/p&gt;

&lt;p&gt;The feature and its hardening landed on July 23, six minutes apart: the second commit is the adversarial review of the first. The module has 48 tests: normalisation, the three outcomes, the bounds of the pre-auth path, and the few static origins we add on top.&lt;/p&gt;

&lt;p&gt;To test the distinction, cache an allowed domain, have the registry explicitly remove it, then make the registry unavailable. The domain should remain absent. Testing an outage alone would miss the case where a cache reverses a decision it had already received.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>architecture</category>
      <category>python</category>
    </item>
    <item>
      <title>Fail-closed or fail-open? Decide per side effect, then check what your cache already decided</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:08:46 +0000</pubDate>
      <link>https://dev.to/goodbarber/fail-closed-or-fail-open-decide-per-side-effect-then-check-what-your-cache-already-decided-2pjg</link>
      <guid>https://dev.to/goodbarber/fail-closed-or-fail-open-decide-per-side-effect-then-check-what-your-cache-already-decided-2pjg</guid>
      <description>&lt;p&gt;Fail-closed: when something a decision depends on is unavailable, the answer is no.&lt;/p&gt;

&lt;p&gt;Fail-open: the answer is yes.&lt;/p&gt;

&lt;p&gt;Fail-bounded: the answer is yes, inside a limit that does not need the missing dependency.&lt;/p&gt;

&lt;p&gt;"Fail-closed or fail-open" is the wrong question at the scale of a service. Ask it per dependency and per kind of request. And before you answer, check your cache client: ours was configured to swallow errors, which turns every security decision built on it into fail-open without anyone deciding so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two words are not enough
&lt;/h2&gt;

&lt;p&gt;The third definition has no standard name, and it is the one we use most. OWASP's principle is called &lt;a href="https://owasp.org/www-community/Fail_securely" rel="noopener noreferrer"&gt;fail securely&lt;/a&gt;, and it is usually read as "always closed". For a service that sits between customer apps and third-party APIs, always closed means that a cache outage takes every app's weather widget down with it. Always open means that the same outage removes the only brake on a connector that can charge a card.&lt;/p&gt;

&lt;p&gt;Both camps are right about their half of the traffic. So the unit of decision cannot be the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;p&gt;The proxy behind this table is one we are trialling at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;. It keeps third-party API keys on the server for widgets that run inside customer apps, and today it serves a few test tenants. Its security specification settles the question in one decision, written before the code: the degraded mode depends on the side effect. A connector is registered either as read-only or as side-effecting, next to its host and its allowed methods, and every dependency gets a row. Everything below is conditional on that flag being right. The proxy applies the column it is given. It does not infer the class from the traffic, so a connector wrongly registered as read-only gets the lenient column.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When this is unavailable&lt;/th&gt;
&lt;th&gt;Read-only connector&lt;/th&gt;
&lt;th&gt;Side-effecting connector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The keys that verify token signatures&lt;/td&gt;
&lt;td&gt;refused, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;refused, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The rate limiter's shared store&lt;/td&gt;
&lt;td&gt;allowed, inside a per-process ceiling&lt;/td&gt;
&lt;td&gt;refused, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The kill switch&lt;/td&gt;
&lt;td&gt;cannot be unavailable on its own: it lives in the primary database and is read on every request&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row has no nuance. A signature you cannot check is not a valid signature, whatever the request does. The status code matters as much as the refusal: &lt;code&gt;503&lt;/code&gt;, never &lt;code&gt;401&lt;/code&gt;. A &lt;code&gt;401&lt;/code&gt; tells the client its token is bad, and a well-behaved client responds by asking for a new one, which turns an outage of the key endpoint into a stampede on the token endpoint.&lt;/p&gt;

&lt;p&gt;The third row is a placement decision. A kill switch that lives in a cache disappears exactly when the infrastructure is having a bad day, which is when you reach for it. Ours is a column in the database the request already needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The option in the middle
&lt;/h2&gt;

&lt;p&gt;The second row did not start that way. In the first version, read-only traffic simply passed when the limiter's store was down. An adversarial review, run before the first commit of that code landed, flagged it, and the reason fits in one sentence. "Read-only" describes the effect on the third party, not the cost to us or to the customer's API quota. Fail-open on reads is still an unmetered pipe.&lt;/p&gt;

&lt;p&gt;The fix is a few lines, and it is the whole idea of fail-bounded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# shared store
&lt;/span&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;StoreUnavailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;side_effecting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;FAIL_CLOSED&lt;/span&gt;                       &lt;span class="c1"&gt;# 503: no brake, no payment
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ALLOW&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;local_window_allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;THROTTLED&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ALLOW&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;THROTTLED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;local_window_allows&lt;/code&gt; is a sliding one-second window kept in the worker's memory. It is cruder than the shared limiter and it needs nothing that can be down. It is also per process, so the real ceiling is that number multiplied by the number of workers. Reads keep flowing, and "how much" still has an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting that had already decided
&lt;/h2&gt;

&lt;p&gt;This is what I would check first in any codebase. Our Redis cache is configured with django-redis's &lt;code&gt;IGNORE_EXCEPTIONS&lt;/code&gt;, which its documentation describes as &lt;a href="https://github.com/jazzband/django-redis#memcached-exceptions-behavior" rel="noopener noreferrer"&gt;memcached-like behaviour&lt;/a&gt;: when the server is unreachable, a read returns &lt;code&gt;None&lt;/code&gt; and a write does nothing. For a cache, that is the right call. A cache that is down should look like a cache that is empty.&lt;/p&gt;

&lt;p&gt;A rate limiter built on that client inherits the setting. &lt;code&gt;incr&lt;/code&gt; does not raise. It returns &lt;code&gt;None&lt;/code&gt;. A naive comparison then either crashes or, written a little differently, treats "no answer" as "under the limit". Nobody chose fail-open. A convenience flag, set once for caching, chose it for every security decision stacked on the same client.&lt;/p&gt;

&lt;p&gt;A shared client's error policy is a security decision for everything built on top of it. So the limiter treats silence as a failure, explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="c1"&gt;# the client swallowed a backend error
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;StoreUnavailable&lt;/span&gt;       &lt;span class="c1"&gt;# silence is a failure, not "under the limit"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of the six tests of that module exists only for this case, and its name says so: &lt;code&gt;none_from_ignore_exceptions_is_treated_as_unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the whole chain during an outage. Redis stops answering. &lt;code&gt;incr&lt;/code&gt; raises nothing and returns &lt;code&gt;None&lt;/code&gt;. The limiter calls that unavailable, and the table decides: &lt;code&gt;503&lt;/code&gt; for a connector that can charge a card, a local ceiling for one that reads the weather.&lt;/p&gt;

</description>
      <category>security</category>
      <category>architecture</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>How do you prevent SSRF in a proxy that calls third-party APIs? Never accept a URL</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:03:13 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-prevent-ssrf-in-a-proxy-that-calls-third-party-apis-never-accept-a-url-4l3o</link>
      <guid>https://dev.to/goodbarber/how-do-you-prevent-ssrf-in-a-proxy-that-calls-third-party-apis-never-accept-a-url-4l3o</guid>
      <description>&lt;p&gt;The contract for our API proxy has no target-URL parameter. A widget sends a connector handle and a relative path. The server loads the destination registered for that connector, then checks whether it is still safe to call.&lt;/p&gt;

&lt;p&gt;That choice rules out the usual request saying, in effect, "fetch this internal address for me". It leaves several ways for a registered destination to become unsafe, including DNS changes and redirects. Those are the cases the rest of this article covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who gets to choose the destination?
&lt;/h2&gt;

&lt;p&gt;We built this proxy for &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, where generated widgets need to call third-party APIs without receiving the private keys themselves. The AI extension feature is still a prototype in a small pilot. The proxy holds the credentials and injects them into outbound requests, which makes its destination checks part of the secret's protection.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP cheat sheet on SSRF&lt;/a&gt; distinguishes services that can restrict calls to known destinations from those that must fetch arbitrary URLs. We chose the first model so that an authenticated owner could approve a destination before a widget used it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: a handle, not a URL
&lt;/h2&gt;

&lt;p&gt;The handle is a random identifier. It selects a server-side record containing the scheme, host, base path and permitted methods. The caller supplies the relative path, query and body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What the widget sends            What the server already holds
-----------------------------    ---------------------------------------
handle:  k3J…x9  (opaque)        base URL:  https://api.example.com/v2
path:    /forecast?city=Paris    allowed hosts, allowed methods
body:    { … }                   the secret, encrypted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the owner approves an API and supplies its key, the base URL goes through the same validation function used for subsequent requests. Approval records the intended destination; it cannot guarantee what that hostname will resolve to later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left to defend
&lt;/h2&gt;

&lt;p&gt;Each outbound request still has to satisfy these checks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vector&lt;/th&gt;
&lt;th&gt;What we do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The host is an IP in disguise: &lt;code&gt;2130706433&lt;/code&gt;, &lt;code&gt;0x7f000001&lt;/code&gt;, &lt;code&gt;127.1&lt;/code&gt;, octal forms&lt;/td&gt;
&lt;td&gt;rejected before any resolution: a destination must be a real hostname, and a name whose labels are all numeric or hexadecimal is refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The name resolves to an internal address&lt;/td&gt;
&lt;td&gt;blocked on the resolved IP, not on the string: loopback, private ranges, link-local and its cloud metadata address, carrier-grade NAT, and their IPv6 equivalents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One public A record and one private one&lt;/td&gt;
&lt;td&gt;every address in the answer is validated, and a single forbidden address rejects the whole answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS rebinding between the check and the connection&lt;/td&gt;
&lt;td&gt;one resolution, then the connection goes to the validated IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An IPv4 address wrapped in IPv6&lt;/td&gt;
&lt;td&gt;IPv4-mapped and NAT64 addresses are unwrapped and the embedded IPv4 is validated too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A redirect to somewhere else&lt;/td&gt;
&lt;td&gt;redirects are never followed, the 30x status goes back to the caller as it is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;../&lt;/code&gt; to escape the registered base path&lt;/td&gt;
&lt;td&gt;the path is normalised first, then compared with the base path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headers that change the meaning of the request&lt;/td&gt;
&lt;td&gt;three headers are relayed from the client: &lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;Accept-Language&lt;/code&gt;. &lt;code&gt;Host&lt;/code&gt;, forwarding headers and method overrides are set or dropped by the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A proxy configured in the host's environment&lt;/td&gt;
&lt;td&gt;the HTTP client ignores environment proxies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lookalike hosts such as &lt;code&gt;api.example.com.evil.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;host matching is exact equality, never a suffix or a pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The NAT64 case came from an adversarial review before the first commit. In the well-known prefix defined by &lt;a href="https://www.rfc-editor.org/rfc/rfc6052" rel="noopener noreferrer"&gt;RFC 6052&lt;/a&gt;, &lt;code&gt;64:ff9b::7f00:1&lt;/code&gt; embeds &lt;code&gt;127.0.0.1&lt;/code&gt;. An IPv6 classification alone can miss the address a translating network would reach. The embedded IPv4 address needs validation too.&lt;/p&gt;

&lt;p&gt;Cloud metadata is one reason that distinction matters. &lt;a href="https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/" rel="noopener noreferrer"&gt;AWS added IMDSv2 in 2019&lt;/a&gt; as defense in depth against SSRF and related paths to instance metadata. Our proxy rejects those destinations rather than relying on the target to resist the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect to the address you validated
&lt;/h2&gt;

&lt;p&gt;Checking a name and then handing the same name to the HTTP client is two resolutions, and the second one can answer differently. The fix is to resolve once and pin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_and_pin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_ips&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# one resolution, A and AAAA
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_blocked_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                   &lt;span class="c1"&gt;# one bad address rejects the whole answer
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SsrfBlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                               &lt;span class="c1"&gt;# the address we will connect to
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request then goes to that IP, while TLS still verifies the real hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pinned_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hostname&lt;/span&gt;                                              &lt;span class="c1"&gt;# the real name, set by the server
&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sni_hostname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# SNI and certificate check too
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Host&lt;/code&gt; header and the SNI carry the real name, so certificate verification is as strict as on a normal call, and it is never disabled. The socket layer has nothing left to resolve. The client is created with &lt;code&gt;follow_redirects=False&lt;/code&gt; and &lt;code&gt;trust_env=False&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read timeout that never fires
&lt;/h2&gt;

&lt;p&gt;An upstream can keep a worker occupied by sending one byte every twenty seconds. That never trips a 25-second &lt;a href="https://www.python-httpx.org/advanced/timeouts/" rel="noopener noreferrer"&gt;httpx read timeout&lt;/a&gt;, because the timeout limits the wait for each chunk. We therefore put a wall-clock deadline around the exchange, including body streaming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="c1"&gt;# httpx's read timeout restarts at every chunk. This deadline does not.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_execute&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TOTAL_DEADLINE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 30 s, for a 25 s read timeout
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response size is capped the same way, by counting the bytes actually streamed, not by trusting a &lt;code&gt;Content-Length&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key arrives last
&lt;/h2&gt;

&lt;p&gt;Decryption happens after the destination checks. A request rejected for its method, path, host or resolved address therefore does not load the plaintext credential. Once admitted, the request receives the key for the approved upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the decision costs
&lt;/h2&gt;

&lt;p&gt;Adding another API requires another approved connector. A widget cannot discover an arbitrary endpoint and ask our server to call it. That restriction suits a platform where an app owner approves which services receive their credentials.&lt;/p&gt;

&lt;p&gt;The egress code landed on July 7 with 105 tests, including 18 for SSRF cases such as numeric hosts, mixed DNS answers, NAT64 and rebinding. Its specification contains 19 egress requirements. The tests cover the checks; the request contract limits what those checks have to accept.&lt;/p&gt;

&lt;p&gt;In a review of another proxy, I would start where the outbound URL is constructed. Trace each component back to its source, then follow the chosen host through resolution and connection. A validator can be correct while the HTTP client quietly makes a different request.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How do you give local content in a WKWebView a real https origin? (iOS 17)</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:45:29 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-give-local-content-in-a-wkwebview-a-real-https-origin-ios-17-2h64</link>
      <guid>https://dev.to/goodbarber/how-do-you-give-local-content-in-a-wkwebview-a-real-https-origin-ios-17-2h64</guid>
      <description>&lt;p&gt;On September 16 I opened a test widget on my iPhone. At the top, a quote that loads when the widget opens. Under it, two text areas and a Compare button that returns a similarity score. Two calls to the same third-party API with the same key: a GET for the quote, a POST for the score.&lt;/p&gt;

&lt;p&gt;The quote appeared. The Compare button answered with the only sentence its code can say when that call fails: "Impossible de calculer la similarité. Veuillez vérifier votre connexion." Unable to compute the similarity, please check your connection. The connection was fine, my Android test phone did exactly the same, and in a browser both calls worked.&lt;/p&gt;

&lt;p&gt;GET works, POST fails. I spent a morning on the verb. The cause was the page's origin, and the GET had never worked.&lt;/p&gt;

&lt;p&gt;This piece is about that origin: where a page in a &lt;code&gt;WKWebView&lt;/code&gt; gets one when its files live on the device, how our iOS engine now serves them from a fixed &lt;code&gt;https://secure.internal&lt;/code&gt; with a proxy API that arrived in iOS 17, and what changing an origin breaks in places no crash log reaches. The widget comes back at the end, once the dates make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why local content needs an origin at all
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app builder for native iOS and Android apps, some sections of an app are web content: custom code written by the app's owner, HTML widgets, extensions. The files live on the device so that the section opens offline. On iOS they are displayed in a &lt;code&gt;WKWebView&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How those files are served decides whether the page is a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" rel="noopener noreferrer"&gt;secure context&lt;/a&gt;, a prerequisite for APIs such as &lt;code&gt;crypto.subtle&lt;/code&gt;. It also sets the origin seen by CORS and CSP checks, cookie behaviour, and where &lt;code&gt;localStorage&lt;/code&gt; and IndexedDB data are stored.&lt;/p&gt;

&lt;p&gt;One of those checks is ours. My team is piloting an extension builder, where I look after the backend: the owner of an app asks for a feature in a sentence, a model writes it, and the result runs in the app's web version and inside its native apps. A widget that needs a private API key never holds it. The page frames a small page served by our token issuer, gets a short-lived token, and calls a proxy of ours that keeps the key. The issuer lets only approved origins frame its page, through a CSP &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors" rel="noopener noreferrer"&gt;&lt;code&gt;frame-ancestors&lt;/code&gt;&lt;/a&gt; directive. So the origin a native shell gives its local files decides whether any of those widgets can work at all. The builder has not shipped to customers, and the phones in this story are my own test devices.&lt;/p&gt;

&lt;p&gt;Change the serving mechanism and all of that changes with it. We went through three versions in one year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three origins in one year
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;How the page was served&lt;/th&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;What it cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before June 2026&lt;/td&gt;
&lt;td&gt;an HTML string with a custom base URL, files through a &lt;code&gt;WKURLSchemeHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;a custom scheme&lt;/td&gt;
&lt;td&gt;not a secure context, an &lt;code&gt;Origin&lt;/code&gt; header that third-party servers do not recognise, &lt;code&gt;Secure&lt;/code&gt; cookies ignored, URL rewriting and a permissive CSP to hold it together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June to August 2026&lt;/td&gt;
&lt;td&gt;a small HTTP server inside the app, bound to loopback&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a secure context and plain HTTP semantics, but an IP address and a changing port in the origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;since September 2, 2026&lt;/td&gt;
&lt;td&gt;a loopback CONNECT proxy in front of a TLS listener&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://secure.internal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS 17 minimum for this approach, and web storage starting from empty once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second step was already a big one, and our iOS team built it as a real server: request parser, range requests for media, keep-alive, recovery when the listener dies. It also relays the page's cross-origin &lt;code&gt;fetch&lt;/code&gt; calls, which the shell rewrites towards it. Loopback is a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" rel="noopener noreferrer"&gt;potentially trustworthy origin&lt;/a&gt;, so the page became a secure context and third-party scripts finally saw an &lt;code&gt;http&lt;/code&gt; origin they understood.&lt;/p&gt;

&lt;p&gt;The remaining problem was the port, assigned at runtime. There was no single origin to put in a remote allowlist. On July 30 the token issuer started accepting &lt;code&gt;http://127.0.0.1:*&lt;/code&gt;, which is valid CSP and accepts any port, including any unrelated local server on the phone. A fixed &lt;code&gt;https&lt;/code&gt; name would give the app and those services a value they could agree on.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebKit will not hand over https
&lt;/h2&gt;

&lt;p&gt;Apple's documentation for &lt;a href="https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/seturlschemehandler%28_:forurlscheme:%29" rel="noopener noreferrer"&gt;&lt;code&gt;setURLSchemeHandler(_:forURLScheme:)&lt;/code&gt;&lt;/a&gt; rules out the direct approach: registering a handler for a scheme WebKit already handles, such as &lt;code&gt;https&lt;/code&gt;, raises an exception.&lt;/p&gt;

&lt;p&gt;Android takes the opposite position. &lt;code&gt;shouldInterceptRequest&lt;/code&gt; lets an app answer any request, &lt;code&gt;https&lt;/code&gt; included, and &lt;a href="https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader" rel="noopener noreferrer"&gt;&lt;code&gt;WebViewAssetLoader&lt;/code&gt;&lt;/a&gt; packages the pattern with a domain reserved for it, &lt;code&gt;appassets.androidplatform.net&lt;/code&gt;. Our Android engine moved from &lt;code&gt;file://&lt;/code&gt; to that loader on July 31, a fixed &lt;code&gt;https&lt;/code&gt; origin came with it, and the issuer accepted it the same day.&lt;/p&gt;

&lt;p&gt;That Android approach does not transfer to &lt;code&gt;WKURLSchemeHandler&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a real certificate for a real domain?
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is a public hostname that resolves to &lt;code&gt;127.0.0.1&lt;/code&gt;, a certificate from a public authority, and the private key shipped in the app. Let's Encrypt has a page about &lt;a href="https://letsencrypt.org/docs/certificates-for-localhost/" rel="noopener noreferrer"&gt;certificates for localhost&lt;/a&gt; whose answer is: don't. A private key distributed in an app is a compromised key, the certificate may be revoked, and the DNS lookup gives an attacker something to intercept. It also makes an offline section depend on DNS.&lt;/p&gt;

&lt;p&gt;We could still use a certificate whose trust was limited to our own WebView. It did not need recognition from a public authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  The piece that arrived with iOS 17
&lt;/h2&gt;

&lt;p&gt;iOS 17 added &lt;a href="https://developer.apple.com/documentation/webkit/wkwebsitedatastore/proxyconfigurations-cdc1" rel="noopener noreferrer"&gt;&lt;code&gt;proxyConfigurations&lt;/code&gt;&lt;/a&gt; to &lt;code&gt;WKWebsiteDataStore&lt;/code&gt;, fed by the Network framework's &lt;a href="https://developer.apple.com/documentation/network/proxyconfiguration" rel="noopener noreferrer"&gt;&lt;code&gt;ProxyConfiguration&lt;/code&gt;&lt;/a&gt;. Apple presented it at WWDC23 for &lt;a href="https://developer.apple.com/videos/play/wwdc2023/10002/" rel="noopener noreferrer"&gt;network relays&lt;/a&gt;, with a remote relay in the example. Nothing says the proxy has to be remote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Network&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;WebKit&lt;/span&gt;

&lt;span class="c1"&gt;// The CONNECT proxy is a listener our own app opened on loopback.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;connectProxyPort&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProxyConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;httpCONNECTProxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tlsOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matchDomains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"secure.internal"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;// only this name uses the proxy&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowFailover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;               &lt;span class="c1"&gt;// never try a direct connection&lt;/span&gt;

&lt;span class="kt"&gt;WKWebsiteDataStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proxyConfigurations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two properties carry the design. With &lt;code&gt;matchDomains&lt;/code&gt;, WebKit sends &lt;code&gt;CONNECT secure.internal:443&lt;/code&gt; to our listener instead of resolving the name, so the name never reaches DNS and the section opens in airplane mode. With &lt;code&gt;allowFailover&lt;/code&gt; off, WebKit never falls back to a direct connection, so the name cannot leak to a resolver while our server is restarting.&lt;/p&gt;

&lt;p&gt;Inside the app, three loopback listeners do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A TLS listener serves the files. Its identity is embedded in the app: self-signed, RSA 2048, a single SAN &lt;code&gt;secure.internal&lt;/code&gt;, TLS 1.2 minimum, ALPN &lt;code&gt;http/1.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A CONNECT proxy on a port the OS assigns. It accepts exactly one request, &lt;code&gt;CONNECT secure.internal:443&lt;/code&gt;, and pipes the bytes to the TLS listener. Anything else gets a 403.&lt;/li&gt;
&lt;li&gt;A plain HTTP fallback for media, in case the media stack does not follow the data store's proxy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The configuration is pushed again every time the server restarts, since the port changes, and before the WebViews are told to reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accept one certificate, for one host
&lt;/h2&gt;

&lt;p&gt;A self-signed certificate fails the system's trust evaluation. That is expected, and the navigation delegate is where it is settled. Ours is Objective-C. Here is the same logic in Swift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WKWebView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;didReceive&lt;/span&gt; &lt;span class="nv"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URLAuthenticationChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="nv"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;@escaping&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;URLSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;AuthChallengeDisposition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;URLCredential&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;space&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protectionSpace&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authenticationMethod&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;NSURLAuthenticationMethodServerTrust&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;caseInsensitiveCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secure.internal"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orderedSame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;trust&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTrust&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;performDefaultHandling&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// every other host: the normal path&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SecTrustCopyCertificateChain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;SecCertificate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;leaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;SecCertificateCopyData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;leaf&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;pinnedCertificateDER&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                     &lt;span class="c1"&gt;// byte-identical to the embedded one&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;useCredential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;URLCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cancelAuthenticationChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exception applies only to that host and that exact certificate. Every other host follows normal trust evaluation.&lt;/p&gt;

&lt;p&gt;The embedded key is shared across our apps and can be extracted from a binary. We treat it accordingly. The design rests on the local routing and on the delegate's narrow trust decision; it does not give the certificate public authority or make it an app identity.&lt;/p&gt;

&lt;p&gt;One more gate sits in front of the files. Loopback listeners are reachable by other processes on the device, so every URL starts with a random token generated for the session, and a request without it gets a 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the name
&lt;/h2&gt;

&lt;p&gt;Since the name never touches DNS, it is cosmetic. It shows up in &lt;code&gt;location.origin&lt;/code&gt;, in the web inspector and in server-side allowlists, never in front of a user. Two constraints still apply: the suffix must never be delegated on the public DNS, and no certificate authority must ever issue for it.&lt;/p&gt;

&lt;p&gt;That rules out &lt;code&gt;.app&lt;/code&gt;, which is a real top-level domain, and &lt;code&gt;.local&lt;/code&gt;, which belongs to multicast DNS and answers on the local network. It points at &lt;code&gt;.internal&lt;/code&gt;, which the ICANN Board &lt;a href="https://www.icann.org/en/board-activities-and-meetings/materials/approved-resolutions-special-meeting-of-the-icann-board-29-07-2024-en" rel="noopener noreferrer"&gt;reserved for private-use applications&lt;/a&gt; on July 29, 2024, permanently excluding it from delegation in the DNS root zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we measured
&lt;/h2&gt;

&lt;p&gt;The new engine landed on September 2, and the issuer swapped &lt;code&gt;http://127.0.0.1:*&lt;/code&gt; for &lt;code&gt;https://secure.internal&lt;/code&gt; the same day. These are the September 4 verification runs: iPhone 15 Pro simulator, iOS 17.4, our internal app in Debug, two real customer apps loaded in it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page loads logging &lt;code&gt;location.origin&lt;/code&gt; and &lt;code&gt;isSecureContext&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;43 loads across plugin sections, HTML widgets and scratch sections, 43 times &lt;code&gt;https://secure.internal&lt;/code&gt; and &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;crypto.subtle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS handshake through the tunnel&lt;/td&gt;
&lt;td&gt;TLS 1.3, 6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import of the embedded identity&lt;/td&gt;
&lt;td&gt;3.7 to 4.0 ms, measured four times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live CONNECT tunnels&lt;/td&gt;
&lt;td&gt;peak of 1, back to 0 after each cycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native bridge, storage round trip, cross-origin GET and POST relays&lt;/td&gt;
&lt;td&gt;exercised on 9 scopes towards 3 remote hosts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backgrounding for 50 seconds, then return&lt;/td&gt;
&lt;td&gt;the WebView survives and the appear event fires again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video rendering&lt;/td&gt;
&lt;td&gt;not testable on the simulator, whose WebKit GPU process is denied GPU access; normal on a device&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the https origin changed
&lt;/h2&gt;

&lt;p&gt;Mixed content appeared. Old custom code links &lt;code&gt;http://fonts.googleapis.com/…&lt;/code&gt; stylesheets, in dozens of HTML files across 12 sections of a single test app. Under an &lt;code&gt;http&lt;/code&gt; origin nobody noticed. Under &lt;code&gt;https&lt;/code&gt;, WebKit blocks them as active mixed content and the page silently loses its fonts. The server now adds &lt;code&gt;Content-Security-Policy: upgrade-insecure-requests&lt;/code&gt; to HTML documents, and to nothing else. Our Android engine met the same files after its own migration.&lt;/p&gt;

&lt;p&gt;Tunnels have to die on the first EOF. A 28-minute capture showed 68 CONNECT tunnels alive at the same instant, all killed together with &lt;code&gt;ENETDOWN&lt;/code&gt; when the app was suspended. Our relay waited for both directions to finish before closing, and WebKit keeps its side open and idle. A CONNECT tunnel is done as soon as either peer stops writing. With that rule, and a counter logged on every open and close, the peak is 1. &lt;code&gt;ENETDOWN&lt;/code&gt; on suspension is now logged as a routine end of life, because it is one.&lt;/p&gt;

&lt;p&gt;Web storage starts from empty. &lt;code&gt;localStorage&lt;/code&gt;, IndexedDB and cookies are filed by origin, and WebKit offers no supported way to move them from one origin to another: &lt;code&gt;WKWebsiteDataStore&lt;/code&gt; can enumerate and delete by origin, not read or rewrite. A custom section that kept a login token asks for the login once after the update. That is the visible price of an origin change.&lt;/p&gt;

&lt;p&gt;The fourth change has no log line at all. It is the widget from the first paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to September 16
&lt;/h2&gt;

&lt;p&gt;The phones ran internal builds of our shells from early summer. That morning I built both engines on my laptop and ran the same widget on fresh installs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Native shell&lt;/th&gt;
&lt;th&gt;GET, the quote&lt;/th&gt;
&lt;th&gt;POST, the score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;none, a browser&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS simulator&lt;/td&gt;
&lt;td&gt;built that morning&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android emulator&lt;/td&gt;
&lt;td&gt;built that morning&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok, 48%, no console error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test iPhone&lt;/td&gt;
&lt;td&gt;internal build, early summer&lt;/td&gt;
&lt;td&gt;a quote was displayed&lt;/td&gt;
&lt;td&gt;error message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test Android phone&lt;/td&gt;
&lt;td&gt;internal build, early summer&lt;/td&gt;
&lt;td&gt;a quote was displayed&lt;/td&gt;
&lt;td&gt;error message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same bundle, same backend, same key. The only variable left was the shell installed on the phones. Read the last two rows again: I wrote "a quote was displayed", not "ok". That morning I wrote "ok".&lt;/p&gt;

&lt;p&gt;The verb was a good suspect, because a WebView does treat a request with a body differently. Browsers attach an &lt;code&gt;Origin&lt;/code&gt; header to a same-origin POST and not to a same-origin GET, as the &lt;a href="https://fetch.spec.whatwg.org/#origin-header" rel="noopener noreferrer"&gt;Fetch standard&lt;/a&gt; specifies, so any guard that compares &lt;code&gt;Origin&lt;/code&gt; to an expected value is only ever exercised by POSTs. On Android, &lt;code&gt;shouldInterceptRequest&lt;/code&gt; hands the app a &lt;a href="https://developer.android.com/reference/android/webkit/WebResourceRequest" rel="noopener noreferrer"&gt;&lt;code&gt;WebResourceRequest&lt;/code&gt;&lt;/a&gt; with a URL, a method and headers, and no body. On iOS, our relay has to buffer the whole body before forwarding anything, and a GET has nothing to buffer. One fact should have cooled me down: both calls carried a bearer token in an &lt;code&gt;Authorization&lt;/code&gt; header, which makes both of them preflighted cross-origin requests. For CORS, my GET and my POST were in the same class.&lt;/p&gt;

&lt;p&gt;The coding agent I was pairing with asked for one trace from the phone, Safari's Web Inspector or &lt;code&gt;chrome://inspect&lt;/code&gt;. I chose to build instead, because a build feels like progress. It worked, and this went into my notes: an outdated app relays the GETs and loses the body of the POSTs; check the build date before you look at the server. The second half is good advice. The first half is a mechanism nobody observed. The fix was right, the cause was invented.&lt;/p&gt;

&lt;p&gt;Two days later I asked the same agent to draft an article from that session. Before writing the mechanism down for strangers, it checked it against git history, and the dates in this article do not allow it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The iOS relay has buffered the complete body since the day it was written, June 4. There is no older relay that drops bodies for a phone to be outdated with.&lt;/li&gt;
&lt;li&gt;On September 2 the issuer stopped accepting &lt;code&gt;http://127.0.0.1:*&lt;/code&gt;. My commit message gives the reason: the feature is unreleased, and an app is rebuilt when the feature is activated for it. An iPhone build older than September 2 can no longer frame the token page. No frame, no token. No token, no call, whatever the verb.&lt;/li&gt;
&lt;li&gt;Android builds older than July 31 load the page from &lt;code&gt;file://&lt;/code&gt;. A &lt;code&gt;file://&lt;/code&gt; page has an opaque origin, and an opaque origin cannot be listed in &lt;code&gt;frame-ancestors&lt;/code&gt;. No token there either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So on those phones the GET could not have worked. Yet both showed a quote. The simulator still had the bundle it downloaded on the 16th, with the function that loads the quote, as the model wrote it (key name and author line shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gbProxyFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/quotes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Erreur API&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quotes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* render quotes[0] */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote-text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;« La simplicité est la sophistication suprême. »&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/* ...and "Léonard de Vinci" in quote-author */&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a shell that cannot frame the token page, the token request fails, the promise rejects, and this &lt;code&gt;catch&lt;/code&gt; prints Leonardo da Vinci. That is what both phones were showing: Leonardo, in French. The POST went through the same failure with nothing to fall back on, so it told me to check my connection.&lt;/p&gt;

&lt;p&gt;The asymmetry was not between two HTTP verbs. It was between two &lt;code&gt;catch&lt;/code&gt; blocks. Nothing had worked on the phones; one of the two failures was dressed as a success. The evidence was on the screen: the API returns its quotes in English, the fallback is in French. And the SDK rejects with a typed error whose &lt;code&gt;kind&lt;/code&gt; names the stage that failed, &lt;code&gt;init&lt;/code&gt; when no token could be obtained. Both &lt;code&gt;catch&lt;/code&gt; blocks received it. Neither read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  An origin is versioned by the binary
&lt;/h2&gt;

&lt;p&gt;A feature that lives in a web page inherits the page's origin, and inside a native app the shell chooses it: &lt;code&gt;file://&lt;/code&gt;, a loopback port, a custom scheme, a fixed &lt;code&gt;https&lt;/code&gt; name. CORS, CSP &lt;code&gt;frame-ancestors&lt;/code&gt;, cookies and every server-side allowlist key on it. So every allowlist that names an origin is, in practice, a list of builds, and the builds are installed on people's phones on dates you do not control.&lt;/p&gt;

&lt;p&gt;The switch of September 2 was a deliberate decision, and it holds: a rebuilt app carries the current shell, the current shell serves the origin the issuer expects, and the two move together. An app owner never meets the old origin. That works because the platform owns both ends, the native shell and the services behind it, which is also why every date in this article is known to the day. The one way to meet the old origin was to test an unreleased feature on an internal build from early summer, on the phone of the person who made the switch.&lt;/p&gt;

&lt;p&gt;On the company blog I wrote about &lt;a href="https://www.goodbarber.com/blog/what-quietly-breaks-when-you-don-t-update-your-app-and-why-you-never-notice-on-goodbarber-a1566/" rel="noopener noreferrer"&gt;what quietly breaks when an app is not updated&lt;/a&gt;: store visibility, push delivery, OS changes. This is the same decay seen from the server side. An allowlist moves, and every build older than that date loses a capability, without a crash and without a line in the phone's log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check first now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is the half that works real?&lt;/strong&gt; Before comparing a GET and a POST, find out whether the GET's data came from the network. Search the page for &lt;code&gt;catch&lt;/code&gt;. If a model wrote the code, expect a fallback: models are polite about failure, they fill the hole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One trace from the failing device.&lt;/strong&gt; Web Inspector or &lt;code&gt;chrome://inspect&lt;/code&gt;, five minutes. It would have shown no request to the proxy at all, neither GET nor POST, and a refused frame in the console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build date of the shell,&lt;/strong&gt; before any server log, whenever the report says "fine on the web, broken in the app". Then compare it with the dates your allowlists changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Substitution, last.&lt;/strong&gt; The same bundle on a fresh shell proves where the fault is not, and says nothing about what happened on the old one. "Works on the simulator I built this morning" and "works on the phone in my pocket" are different claims. That gap is where a correct fix picked up an invented cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model's fallback stays. A quote widget that shows a default quote instead of a broken box is the right behaviour for the person using the app; it is only a trap for the person debugging it. So its firing becomes visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[quote] proxy call failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the line that was missing&lt;/span&gt;
  &lt;span class="nf"&gt;showFallbackQuote&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that line, the first look at the console says &lt;code&gt;init&lt;/code&gt;, no token, before anyone opens Xcode. Degrade gracefully for the user, loudly for the developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the migration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;https://secure.internal&lt;/code&gt; is shared across our apps, just as &lt;code&gt;appassets.androidplatform.net&lt;/code&gt; is shared by Android apps using that loader. It gives browser policies a stable value to compare. Authentication still needs its own mechanism, which is what the token is for.&lt;/p&gt;

&lt;p&gt;The practical gain is that local content gets ordinary HTTPS behaviour while staying available offline. The cost sits in everything attached to the previous origin: storage, remote allowlists, and the builds already installed. That is why the new name is boring, fixed, and meant to stay. Changing it later is another migration, even if every HTML file stays the same, and the phones in people's pockets will not follow.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>webdev</category>
      <category>security</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The assistant is making the native app mandatory again</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:25:35 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-assistant-is-making-the-native-app-mandatory-again-dh5</link>
      <guid>https://dev.to/goodbarber/the-assistant-is-making-the-native-app-mandatory-again-dh5</guid>
      <description>&lt;p&gt;For ten years, a well-built website was an acceptable answer to the question "do I need an app?". This year, Apple and Google settled how the phone's assistant will act for us, and that answer no longer quite holds. The app is becoming an entry ticket again, and not for the reasons of 2008.&lt;/p&gt;

&lt;p&gt;My colleague Dominique Siacci explained at the end of September &lt;a href="https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6"&gt;why agents won't kill apps&lt;/a&gt;: on a phone, they can't act without them, because the app already carries the identity, the rights and the relationship an agent needs. I'd like to draw the consequence for the question we've been asked for fifteen years, and which is changing its answer for the third time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three answers to the same question
&lt;/h2&gt;

&lt;p&gt;I've been building app platforms since 2011, and I've watched the answer to "do I need an app?" change twice.&lt;/p&gt;

&lt;p&gt;The first answer was yes, no discussion. Between 2008 and 2014, being on the home screen meant existing. An icon on the phone was worth a shop window downtown. That rush is what gave birth to GoodBarber, and to dozens of tools like it.&lt;/p&gt;

&lt;p&gt;Then the web took back control. &lt;a href="https://www.goodbarber.com/blog/ios-opens-its-doors-to-progressive-web-apps-a898/" rel="noopener noreferrer"&gt;Progressive Web Apps&lt;/a&gt;, Google pushing the browser as a platform, and a decade of "you don't need an app, a website is enough". The argument was solid. So solid that we added a PWA engine next to our native engines, in 2017, and we still sell it. One project description, three outputs: Swift, Kotlin, and the web. When I say the app had become optional, I say it as someone who built the option.&lt;/p&gt;

&lt;p&gt;The third answer is taking shape this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed this year
&lt;/h2&gt;

&lt;p&gt;At WWDC in June, Apple made &lt;a href="https://developer.apple.com/videos/play/wwdc2026/343/" rel="noopener noreferrer"&gt;App Intents&lt;/a&gt; the door through which Siri gets into third-party apps, and deprecated the old mechanism, SiriKit. iOS 27, released on September 14, launched a new Siri meant to act and search inside apps from a single sentence. It's early days. &lt;a href="https://www.apple.com/newsroom/2026/09/siri-ai-a-profoundly-more-capable-and-personal-assistant-is-here/" rel="noopener noreferrer"&gt;According to Apple&lt;/a&gt;, it's in beta and English first. It isn't available on iPhones in the European Union, and actions in third-party apps are coming "soon". Android is heading the same way with &lt;a href="https://developer.android.com/blog/posts/the-intelligent-os-making-ai-agents-more-helpful-for-android-apps" rel="noopener noreferrer"&gt;AppFunctions&lt;/a&gt;, shipped in Android 16 and still in beta with Gemini. Dominique covers the mechanism, so I won't go over it again.&lt;/p&gt;

&lt;p&gt;The door itself isn't new: App Intents have existed since 2022. What changes this year is that Apple makes it the standard way in, and that the assistant will walk through it on its own, from a sentence.&lt;/p&gt;

&lt;p&gt;For the question of this article, one thing matters: both mechanisms are native development kits, built into an app's binary, and a web page can't register with them. There is work on the web side, &lt;a href="https://developer.chrome.com/blog/webmcp-epp" rel="noopener noreferrer"&gt;WebMCP&lt;/a&gt;, but it targets agents running in the browser, on an open page. The phone's assistant only looks at what's installed. I'm not saying anything about adoption, which nobody knows, or about when everyone will get access. I'm saying the entry ticket is defined, by both platforms, the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't 2008
&lt;/h2&gt;

&lt;p&gt;It's tempting to read this as a step back.&lt;/p&gt;

&lt;p&gt;In 2008, having an app was about capturing attention: an icon on the home screen, notifications. The app was a shop window. Tomorrow, having an app will be about being present in the assistant: when someone says "get me a seat for Saturday's concert", it's your app or someone else's that answers. The first was won with design and marketing. The second is won by describing your app, what it does as well as what it holds, precisely enough for a machine to use it.&lt;/p&gt;

&lt;p&gt;There's a condition, and Dominique puts it himself on his list of what would prove him wrong: the assistant only calls what's installed. To be present in the assistant, you first have to be installed, and that's exactly the 2008 problem: the shop window is still the prerequisite.&lt;/p&gt;

&lt;p&gt;This isn't the story &lt;a href="https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp"&gt;I told in September&lt;/a&gt; either. There, I was talking about the cost of building: coding agents make it cheap to build the same app three times. Here, it's about distribution: who gets to be in the assistant. Two different mechanisms, pushing in the same direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, do you need an app in 2026?
&lt;/h2&gt;

&lt;p&gt;The question "do I need an app?" has had one answer per era. Yes, to be seen. No, a website is enough. And now: if you want to exist, one way or another, in the phone's assistant, yes.&lt;/p&gt;

&lt;p&gt;First, to act. Booking a class, ordering, tracking a delivery, moving an appointment: these are exactly the sentences people will say to the assistant, and it can only carry them out in a native app that has declared those actions.&lt;/p&gt;

&lt;p&gt;But also to be found. The assistant searches as much as it acts. When someone asks it to "find me a concert in Ajaccio next weekend", it will look in the content that installed apps have described to it: Apple &lt;a href="https://developer.apple.com/videos/play/wwdc2026/240/" rel="noopener noreferrer"&gt;explicitly plans&lt;/a&gt; for Siri to answer questions about an app's content. It's already what apps built on GoodBarber do when you ask Siri "what's new?", from iOS 17, without their owners having to touch anything. The rollout happens in stages, and my colleague João Aleixo &lt;a href="https://www.goodbarber.com/blog/siri-actions-the-mcp-of-your-ios-app-a1616/" rel="noopener noreferrer"&gt;explains how on our blog&lt;/a&gt;. A cultural events calendar or a local news outlet has as much to gain as a booking app.&lt;/p&gt;

&lt;p&gt;And the assistant doesn't only live in the phone. In the car, few apps get a place on the CarPlay screen: Apple reserves it for a few categories, navigation or audio for instance, and rebuilding an interface for the dashboard rarely makes sense. But Siri is in the car, and an app that has declared its actions can answer there by voice.&lt;/p&gt;

&lt;p&gt;The website keeps its role: it's what people find on Google, and it's what welcomes the visitor who comes once and won't install anything. We still sell PWAs, and many projects will have both: the website for Google, the app for the assistant.&lt;/p&gt;

&lt;p&gt;It isn't urgent. If your users are in Europe, the new Siri hasn't reached them yet, and I don't know when it will. But the entry ticket is already set, and it's a native app.&lt;/p&gt;

&lt;p&gt;The web had made the app optional. The assistant is making it mandatory for anyone who wants to be within earshot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Mathieu Poli, Head of Frontend Engineering @ &lt;a href="https://goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;. I teach and write about frontend engineering, product design, and AI, and everything that happens when the three meet.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>mcp</category>
      <category>mobile</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
