<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Lessons learned</title><link>https://eghuro.cz/</link><description>Recent content on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sun, 08 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://eghuro.cz/index.xml" rel="self" type="application/rss+xml"/><item><title>Offline login in an iOS or Android app: design it as offline unlock</title><link>https://eghuro.cz/posts/offline-login-is-offline-unlock/</link><pubDate>Tue, 03 Nov 2026 18:00:00 +0100</pubDate><guid>https://eghuro.cz/posts/offline-login-is-offline-unlock/</guid><description>&lt;p&gt;A product request for &amp;ldquo;offline login&amp;rdquo; in a mobile app with a cache of sensitive data (customer invoices, HR records, patient notes, credentials) sounds like one feature. On a phone with no network, nothing can check the password against the server, apply MFA, or learn that the device was revoked yesterday. What the app can do is prove a local factor to a device that was enrolled earlier and release keys for ciphertext already on it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;offline login: authenticate the user, apply policy, authorize data (impossible offline)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;offline unlock: prove a local factor, release keys for cached data (what you can build)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I researched how to build the second one on iOS and Android. This post collects the parts that are easy to get wrong: where the password path needs a hardware key, why a biometric prompt without a &lt;code&gt;CryptoObject&lt;/code&gt; is only a boolean check, which clock keeps counting while the phone sleeps, and what changes for users who have no password at all. It is longer than a usual post here, because the traps sit in different layers.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-an-offline-unlock-can-and-cannot-prove"&gt;What an offline unlock can and cannot prove &lt;/h2&gt;&#10;&lt;p&gt;A connected sign-in lets the server authenticate the user and the device, require MFA, evaluate current policy and revocation, and send newly authorized data. An offline unlock does none of that. So it must never renew a server session, refresh authorization, or make cached data look current. Label the action &amp;ldquo;Unlock&amp;rdquo; in the UI and keep &amp;ldquo;Sign in&amp;rdquo; for the server. Show &amp;ldquo;Last checked&amp;rdquo; (policy) and &amp;ldquo;Last synchronized&amp;rdquo; (data) separately, because a recent local unlock is neither.&lt;/p&gt;&#10;&lt;p&gt;The trade-off between availability and revocation is unavoidable. While the device is disconnected, a revocation event cannot reach it, and plaintext it already showed cannot be recalled. A long offline window helps users during outages and travel. It also lets a lost or deauthorized device keep unlocking its cache until the window expires or the device reconnects. Pick a maximum offline age per threat model, make it visible, and apply revocation before any sync or key release at the next connection. Managed users should get an administrator-set finite limit. If you offer an unlimited consumer mode, tell the user that remote revocation and policy changes wait for the next reconnect.&lt;/p&gt;&#10;&lt;p&gt;First use of a device is always a connected sign-in. It enrolls the device, downloads the encrypted state, and creates the local unlock material. A device that was never provisioned online must never create offline capability, and a network error must never let you skip a required connected step.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-state-machine-where-a-stale-device-never-prompts"&gt;A state machine where a stale device never prompts &lt;/h2&gt;&#10;&lt;p&gt;Write the states down, so a missing transition shows up in review:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ connected sign-in + MFA + policy + initial sync ─▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness valid, then valid local factor ────────▶ UNLOCKED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful check-in ─────────────────────────────▶ LOCKED_CURRENT (freshness renewed)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness expires ───────────────────────────────▶ LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UNLOCKED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ timeout/background/device lock/manual lock ──────▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful check-in ─────────────────────────────▶ UNLOCKED (freshness renewed)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness expires ───────────────────────────────▶ LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful connected reauthentication/check-in ──▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A check-in is any authenticated server call that returns current policy, revocation state, and server time. A background call with a refresh token is enough. If policy also wants full reauthentication with MFA after some maximum age, put that in the policy record, not in app logic.&lt;/p&gt;&#10;&lt;p&gt;Evaluate freshness before asking for a factor. A stale device shows the reconnect screen and never runs the password KDF, a PIN check, or a biometric prompt. When the window has expired, a biometric prompt that is bound to fail, followed by a generic &amp;ldquo;wrong password&amp;rdquo; error, tells the user nothing about the fix.&lt;/p&gt;&#10;&lt;p&gt;Losing one local factor does not change state. When a biometric wrapper is invalidated, or the PIN hits its threshold, delete that wrapper and continue with the others.&lt;/p&gt;&#10;&#10;&lt;h2 id="wrap-keys-never-encrypt-data-with-the-password"&gt;Wrap keys; never encrypt data with the password &lt;/h2&gt;&#10;&lt;p&gt;Use envelope encryption:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Generate a random 256-bit account data key (ADK).&lt;/li&gt;&#10;&lt;li&gt;Generate a random key per record and per file and wrap each under the ADK. Do not derive them from the ADK. A derived key can always be derived again, so you cannot destroy or rotate it for one object without rotating the ADK.&lt;/li&gt;&#10;&lt;li&gt;Encrypt record fields, search indexes, file metadata, thumbnails, and file content with an AEAD: AES-256-GCM or ChaCha20-Poly1305.&lt;/li&gt;&#10;&lt;li&gt;Wrap the ADK once per unlock method: password, biometric, optional PIN, recovery key.&lt;/li&gt;&#10;&lt;li&gt;Encrypt the password and PIN wrappers a second time under a non-exportable hardware key that needs no user authentication: Secure Enclave on iOS, Android Keystore on Android.&lt;/li&gt;&#10;&lt;li&gt;Put version, algorithm, KDF parameters, key ID, account, object ID, field ID, and purpose into the authenticated data.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The field ID in step 6 looks like overkill until a field moves. If an attacker can swap the bank-account field of supplier record A with that of record B and the AEAD still verifies, the app shows a payment destination nobody entered for that supplier. In an autofill provider, the same swap between URL fields offers entry A&amp;rsquo;s password to site B.&lt;/p&gt;&#10;&lt;p&gt;A whole-database encryption layer can hide metadata and WAL contents. It does not replace per-field AEAD, because it usually protects only while the database is closed.&lt;/p&gt;&#10;&#10;&lt;h3 id="why-the-password-path-needs-the-device-too"&gt;Why the password path needs the device too &lt;/h3&gt;&#10;&lt;p&gt;The threat model that changes the design is the attacker with a copied sandbox or backup. Assume they hold the database, salts, KDF parameters, wrapped keys, and preferences. If the password wrapper is only &lt;code&gt;KDF(password)&lt;/code&gt;, they can run the KDF on GPUs, away from the device, for as long as they like.&lt;/p&gt;&#10;&lt;p&gt;Step 5 fixes that. With the wrapper sealed by a device key that never leaves the Secure Enclave or Keystore, every guess has to run on the original phone, at its speed. The KDF becomes the second barrier, not the only one. A secret stored in plaintext next to the copied files is not a second factor and does not count as one.&lt;/p&gt;&#10;&lt;p&gt;For the KDF itself:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Prefer Argon2id with a random salt and versioned parameters, benchmarked on the weakest supported device. It is not part of CryptoKit, CommonCrypto, or the standard Android crypto APIs, so it is a third-party dependency with its own review, pinning, and update process.&lt;/li&gt;&#10;&lt;li&gt;If you have to use PBKDF2, both platforms ship it: &lt;code&gt;CCKeyDerivationPBKDF&lt;/code&gt; and &lt;code&gt;SecretKeyFactory.getInstance(&amp;quot;PBKDF2WithHmacSHA256&amp;quot;)&lt;/code&gt;. Calibrate the iteration count on real devices instead of copying one from another project.&lt;/li&gt;&#10;&lt;li&gt;Store the parameters with the envelope and migrate them after a successful unlock.&lt;/li&gt;&#10;&lt;li&gt;Derive separate keys for local wrapping and for any protocol authentication, with domain separation.&lt;/li&gt;&#10;&lt;li&gt;On iOS, hold keys in CryptoKit &lt;code&gt;SymmetricKey&lt;/code&gt;, which zeroes its storage on release, not in &lt;code&gt;Data&lt;/code&gt; that the runtime may copy. A password captured as a Swift &lt;code&gt;String&lt;/code&gt; cannot be wiped; turn it into bytes for the KDF right away.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;KDF cost does not stop a patient person typing guesses into the unmodified app, so add an increasing delay and a persisted failure counter, and require a connected sign-in after a threshold.&lt;/p&gt;&#10;&#10;&lt;h3 id="a-six-digit-pin-is-a-million-candidates"&gt;A six-digit PIN is a million candidates &lt;/h3&gt;&#10;&lt;p&gt;An app PIN is a convenience secret in front of the password. It unlocks its own envelope, and that envelope has to be bound to a non-exportable platform key. Six digits are 10^6 candidates. An unbound PIN envelope falls to offline search, whatever the KDF costs.&lt;/p&gt;&#10;&lt;p&gt;The rest of the PIN rules:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;At least six digits; off by default for high-assurance or managed profiles.&lt;/li&gt;&#10;&lt;li&gt;Never store a PIN verifier that is cheaper to attack than the envelope.&lt;/li&gt;&#10;&lt;li&gt;A failure counter scoped to the user that survives process death and reboot, with increasing delay and at most 10 consecutive failures, the upper bound NIST SP 800-63B sets for activation secrets.&lt;/li&gt;&#10;&lt;li&gt;At the threshold, delete the PIN wrapper and ask for the password. Wiping the local data would destroy the only copy of anything not yet synced.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The catch: neither iOS nor Android enforces a retry limit in hardware for an app-defined PIN. NIST&amp;rsquo;s limit assumes the authenticator enforces it, and here the app does. The counter stops the user and a thief using the unmodified app. On a rooted or jailbroken device, an attacker can query the platform key directly at hardware speed, and only PIN length helps against that.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-biometric-prompt-must-unlock-a-key"&gt;The biometric prompt must unlock a key &lt;/h2&gt;&#10;&lt;p&gt;Biometric success has to authorize the cryptographic operation that unwraps the ADK. If it only flips an &lt;code&gt;isAuthenticated&lt;/code&gt; flag, a hooked callback on a rooted device skips it.&lt;/p&gt;&#10;&#10;&lt;h3 id="android-pass-a-cryptoobject"&gt;Android: pass a &lt;code&gt;CryptoObject&lt;/code&gt; &lt;/h3&gt;&#10;&lt;p&gt;Generate the key inside Keystore and require authentication for every use:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KeyGenParameterSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;offline-data-wrap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURPOSE_ENCRYPT&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURPOSE_DECRYPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setKeySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setBlockModes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BLOCK_MODE_GCM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setEncryptionPaddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENCRYPTION_PADDING_NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUserAuthenticationRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUserAuthenticationParameters&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="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTH_BIOMETRIC_STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 30+: every use prompts&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setInvalidatedByBiometricEnrollment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUnlockedDeviceRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 28+&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setIsStrongBoxBacked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 28+; on StrongBoxUnavailableException, retry in the TEE&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nc"&gt;KeyGenerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEY_ALGORITHM_AES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AndroidKeyStore&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then hand the initialized cipher to &lt;code&gt;BiometricPrompt&lt;/code&gt;, and decrypt only with the cipher you get back:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cipher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;AES/GCM/NoPadding&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DECRYPT_MODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wrappingKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GCMParameterSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KeyPermanentlyInvalidatedException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;deleteBiometricWrapper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// enrolled biometrics changed: fall back to the password&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onAuthenticationSucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;unlocked&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cryptoObject&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updateAAD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;adk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doFinal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PromptInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Unlock offline data&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setAllowedAuthenticators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BiometricManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Authenticators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BIOMETRIC_STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setNegativeButtonText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Use password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CryptoObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set the accepted authenticators explicitly. A default that includes &lt;code&gt;DEVICE_CREDENTIAL&lt;/code&gt; means the device PIN opens the offline data, and so does anyone who watched the owner type it. If you want that fallback, make it a written product decision. Since this is a symmetric key with authentication required, creating the wrapper at enrollment also needs a prompt; ask for the password first, as with any new factor.&lt;/p&gt;&#10;&#10;&lt;h3 id="ios-a-secure-enclave-key-and-ecies"&gt;iOS: a Secure Enclave key and ECIES &lt;/h3&gt;&#10;&lt;p&gt;Generate the wrapping key in the Secure Enclave with biometric access control, and wrap the ADK with ECIES:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Unmanaged&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CFError&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecAccessControlCreateWithFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the OS deletes it if the passcode is removed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="n"&gt;privateKeyUsage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;biometryCurrentSet&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// no passcode fallback&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Any&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrKeyType&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecAttrKeyTypeECSECPrimeRandom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrKeySizeInBits&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrTokenID&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecAttrTokenIDSecureEnclave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecPrivateKeyAttrs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrIsPermanent&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrApplicationTag&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;offline-data-wrap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrAccessControl&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;privateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecKeyCreateRandomKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CFDictionary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wrapped&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecKeyCreateEncryptedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;SecKeyCopyPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privateKey&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eciesEncryptionCofactorX963SHA256AESGCM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;adk&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CFData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To unwrap, fetch the key with a fresh &lt;code&gt;LAContext&lt;/code&gt; passed as &lt;code&gt;kSecUseAuthenticationContext&lt;/code&gt;, with &lt;code&gt;touchIDAuthenticationAllowableReuseDuration&lt;/code&gt; left at 0, and call &lt;code&gt;SecKeyCreateDecryptedData&lt;/code&gt;. The prompt the user sees then authorizes this key use. That is the iOS counterpart of Android&amp;rsquo;s per-use authentication.&lt;/p&gt;&#10;&lt;p&gt;Two details to get right:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The accessibility class goes into &lt;code&gt;SecAccessControlCreateWithFlags&lt;/code&gt;. It cannot be combined with a separate &lt;code&gt;kSecAttrAccessible&lt;/code&gt; attribute on the same item.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;.biometryCurrentSet&lt;/code&gt; invalidates the key when Face ID or Touch ID enrollment changes. Handle that by deleting the wrapper and requiring the password. A &lt;code&gt;.devicePasscode&lt;/code&gt; or &lt;code&gt;.userPresence&lt;/code&gt; fallback lets anyone who knows the device passcode into the offline data.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="data-protection-classes-that-fail-after-lock"&gt;Data Protection classes that fail after lock &lt;/h2&gt;&#10;&lt;p&gt;Set a Data Protection class per store instead of relying on container defaults. The &lt;code&gt;complete&lt;/code&gt; class key is discarded about 10 seconds after the device locks, which bites in three places:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Plaintext temporaries should use &lt;code&gt;complete&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Background downloads of offline files need &lt;code&gt;completeUnlessOpen&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;A SQLite database in WAL mode under &lt;code&gt;complete&lt;/code&gt; fails when anything touches it after lock. Either stop database access on &lt;code&gt;protectedDataWillBecomeUnavailable&lt;/code&gt;, or use &lt;code&gt;completeUntilFirstUserAuthentication&lt;/code&gt; for the database and rely on field-level AEAD.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Files in &lt;code&gt;tmp/&lt;/code&gt; and elsewhere in the container default to &lt;code&gt;completeUntilFirstUserAuthentication&lt;/code&gt; unless you set otherwise.&lt;/p&gt;&#10;&#10;&lt;h2 id="offline-expiry-needs-a-clock-that-counts-sleep"&gt;Offline expiry needs a clock that counts sleep &lt;/h2&gt;&#10;&lt;p&gt;Enforcing maximum offline age with wall-clock time alone fails the moment someone sets the clock back. I covered that half in &lt;a&#10; href="https://eghuro.cz/posts/offline-cache-clock-rollback"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;An offline cache TTL needs a clock-rollback policy&lt;/a&gt;&#10;. The research added three things.&lt;/p&gt;&#10;&lt;p&gt;The freshness record has two parts. A server-signed part (check-in time, policy version, expiry duration), verified with the server&amp;rsquo;s public key. A local part (wall time at check-in, monotonic time at check-in, boot reference, accumulated elapsed time), authenticated with a keystore device key. Do not protect it with the ADK: freshness is evaluated before accepting any factor, so the record must verify while the app is locked.&lt;/p&gt;&#10;&lt;p&gt;The monotonic clock must keep counting while the phone sleeps, because a phone is asleep most of the time:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Platform&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Use&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Stops during sleep&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;iOS&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;mach_continuous_time()&lt;/code&gt;, &lt;code&gt;clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;mach_absolute_time()&lt;/code&gt;, &lt;code&gt;ProcessInfo.systemUptime&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Android&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SystemClock.elapsedRealtime()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SystemClock.uptimeMillis()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Verify the chosen clock on a device by letting it sleep.&lt;/p&gt;&#10;&lt;p&gt;Reboots reset the monotonic clock. Android has &lt;code&gt;Settings.Global.BOOT_COUNT&lt;/code&gt;. iOS has no documented boot identifier: &lt;code&gt;kern.boottime&lt;/code&gt; moves when the user changes the wall clock, so it detects clock changes, not boots. On iOS, detect a reboot by a monotonic reading lower than the stored one. Then:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;update accumulated elapsed time on every foreground, unlock, and lock&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;same boot: elapsed from the monotonic clock, cross-checked against wall time&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;after reboot: elapsed = max(accumulated + monotonic_since_boot,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; wall_now - wall_at_checkin)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wall_now &amp;lt; last stored wall time → rollback → stale&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A rollback across a reboot can then hide at most the time between the last update and the reboot.&lt;/p&gt;&#10;&lt;p&gt;Fail closed on a missing, rolled-back, or inconsistent record, and exclude it from backup. A restore, to another device or to the same one, then leaves the record missing and forces a check-in. Otherwise, a same-device restore brings back an older valid record together with the &lt;code&gt;ThisDeviceOnly&lt;/code&gt; Keychain items it belongs to.&lt;/p&gt;&#10;&lt;p&gt;These controls resist clock rollback, not state rollback. Nothing stops someone from replacing the current record with an older valid copy, and the expiry is application logic. Like the PIN counter, it binds the unmodified app, not an attacker running their own code against the device keys.&lt;/p&gt;&#10;&#10;&lt;h2 id="documents-chunked-aead-and-plaintext-that-outlives-the-app"&gt;Documents: chunked AEAD and plaintext that outlives the app &lt;/h2&gt;&#10;&lt;p&gt;Offline documents, such as invoice PDFs or scanned contracts, need more than per-record encryption. CryptoKit&amp;rsquo;s &lt;code&gt;AES.GCM&lt;/code&gt; and &lt;code&gt;ChaChaPoly&lt;/code&gt; seal a whole message in memory. Streaming playback and seeking viewers need chunks, and naive chunking lets an attacker truncate, reorder, or drop chunks without failing authentication. Use a format in the style of the STREAM construction:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;fixed-size chunks (say 64 KiB), each sealed separately;&lt;/li&gt;&#10;&lt;li&gt;a nonce built from a random per-file prefix and the chunk index;&lt;/li&gt;&#10;&lt;li&gt;file ID, format version, chunk index, and a final-chunk flag in each chunk&amp;rsquo;s authenticated data;&lt;/li&gt;&#10;&lt;li&gt;an authenticated header with the prefix, chunk size and version;&lt;/li&gt;&#10;&lt;li&gt;a new per-file key or nonce prefix whenever the file changes, so no key and nonce pair is ever reused.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Prefer decrypting into memory or a pipe for an in-app viewer. &lt;code&gt;QLPreviewController&lt;/code&gt; wants a file URL, so some formats force a plaintext file. When one is unavoidable, put it in a dedicated private directory under &lt;code&gt;complete&lt;/code&gt;, with an unpredictable name, excluded from backup and indexing, shared only through a narrow, non-exported &lt;code&gt;FileProvider&lt;/code&gt; grant on Android.&lt;/p&gt;&#10;&lt;p&gt;The trap is cleanup. A suspended iOS app runs no code, so no timer deletes the file on schedule, and cancellation, render errors, crashes, process death, and reboot all skip view-level cleanup. Make the launch path enumerate and delete expired temporaries before it allows an unlock.&lt;/p&gt;&#10;&#10;&lt;h2 id="lock-logout-and-keychain-items-that-survive-uninstall"&gt;Lock, logout, and Keychain items that survive uninstall &lt;/h2&gt;&#10;&lt;p&gt;Lock keeps the encrypted state and destroys the usable keys: release the ADK and derived keys, close plaintext streams, blank the app-switcher snapshot, invalidate decrypted caches, and stop background jobs from quietly recreating an unlocked session. A suspended app cannot clear the clipboard later, so set the expiry when you copy:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UIPasteboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;general&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;UTType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8PlainText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="n"&gt;localOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expirationDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;.localOnly&lt;/code&gt; keeps it off Universal Clipboard. On Android, mark the clip with &lt;code&gt;ClipDescription.EXTRA_IS_SENSITIVE&lt;/code&gt; (API 33+) and clear it with &lt;code&gt;ClipboardManager.clearPrimaryClip()&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Logout makes the state permanently unusable. Physical overwrite on flash is unreliable, so the primary boundary is cryptographic erasure: destroy every usable wrapping key, then delete tokens, databases with their WAL and journal files, indexes, cached files, temporaries, pending operations, and shared-container state.&lt;/p&gt;&#10;&lt;p&gt;On iOS, uninstalling the app deletes its container but leaves its Keychain items, and no app code runs during uninstall. Keep a marker file in the container. When it is missing at launch, delete every Keychain item the app owns, shared access groups included, before doing anything else. Otherwise, a reinstall can find the previous install&amp;rsquo;s wrappers.&lt;/p&gt;&#10;&#10;&lt;h2 id="autofill-is-offline-unlock-under-another-name"&gt;Autofill is offline unlock under another name &lt;/h2&gt;&#10;&lt;p&gt;If the cached data includes credentials and the app offers an autofill provider, that provider releases secrets from the local cache with no server call, so every rule above applies to it. The platforms constrain it differently.&lt;/p&gt;&#10;&lt;p&gt;On iOS, the Credential Provider extension is a separate process with its own unlock session. It sees the app&amp;rsquo;s state only through a shared Keychain access group and an App Group container, so every key and wrapper it needs, including the Secure Enclave device key and the freshness record, must live in the shared group. It runs under a memory limit Apple describes as only slightly lower than an app&amp;rsquo;s. That limit caps the Argon2id memory parameter, so benchmark inside the extension on the weakest device. Do not give the extension a cheaper copy of the password envelope: the weakest wrapper sets the strength of all the data it protects. If parameters that fit are too weak, let the extension offer biometrics only and send password users to the app.&lt;/p&gt;&#10;&lt;p&gt;An &lt;code&gt;LAContext&lt;/code&gt; evaluated in the app does not carry over to the extension. Within one request, the extension can pass one evaluated context to several Keychain reads, so a single prompt covers a list. The system may reuse the extension process for the next request, so nothing decrypted may stay in a static.&lt;/p&gt;&#10;&lt;p&gt;On Android, the &lt;code&gt;AutofillService&lt;/code&gt; runs in the app&amp;rsquo;s own process unless the manifest says otherwise. Its authentication activity still needs a &lt;code&gt;CryptoObject&lt;/code&gt;, and it should accept the same authenticators as the app.&lt;/p&gt;&#10;&lt;p&gt;Rules for both:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Give the provider its own key that wraps only what a fill needs (username, password, URL), so a faulty extension cannot decrypt documents, notes, or indexes.&lt;/li&gt;&#10;&lt;li&gt;Share the failure counter between app and provider. Otherwise, an attacker doubles the allowed attempts by alternating between them.&lt;/li&gt;&#10;&lt;li&gt;The provider reads freshness and never checks it itself. When stale, it fills nothing and says which app to open.&lt;/li&gt;&#10;&lt;li&gt;On iOS, the identity store shows registered usernames next to their domains in the QuickType bar to anyone holding the unlocked phone, with the app locked. Replace the whole identity set after each sync instead of removing and re-adding the current ones, so deleted entries disappear, and consider a managed policy that registers none.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h3 id="verify-who-is-asking"&gt;Verify who is asking &lt;/h3&gt;&#10;&lt;p&gt;The requester check is where autofill phishing happens (&lt;a&#10; href="https://doi.org/10.1145/3243734.3243778"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Aonzo et al., &lt;em&gt;Phishing Attacks on Modern Android&lt;/em&gt;, CCS 2018&lt;/a&gt;&#10;). iOS identifies websites and apps through associated domains. On Android, the provider has to do it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Verify the requesting app&amp;rsquo;s signing certificate against the domain&amp;rsquo;s &lt;a&#10; href="https://developers.google.com/digital-asset-links"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Digital Asset Links&lt;/a&gt;&#10;, not its package name. An app named &lt;code&gt;com.examplebank.evil&lt;/code&gt; is not Example Bank.&lt;/li&gt;&#10;&lt;li&gt;Trust browsers by package name and certificate digest together. On a device without a listed browser, a sideloaded app can take that browser&amp;rsquo;s package name and report any web domain.&lt;/li&gt;&#10;&lt;li&gt;Derive registrable domains from the &lt;a&#10; href="https://publicsuffix.org"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Public Suffix List&lt;/a&gt;&#10;, not a hand-written list of suffixes. Otherwise, an entry saved for &lt;code&gt;victim.github.io&lt;/code&gt; collapses to &lt;code&gt;github.io&lt;/code&gt; and fills in &lt;code&gt;attacker.github.io&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="users-without-a-password"&gt;Users without a password &lt;/h2&gt;&#10;&lt;p&gt;Everything above assumes every user has a password. Users who sign in through enterprise SSO or a partner&amp;rsquo;s authenticator app do not. In this design, the password has five jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;the baseline wrapper of the ADK;&lt;/li&gt;&#10;&lt;li&gt;the fallback when a biometric wrapper is invalidated;&lt;/li&gt;&#10;&lt;li&gt;the authorization for a new biometric wrapper;&lt;/li&gt;&#10;&lt;li&gt;the replacement when the PIN hits its threshold;&lt;/li&gt;&#10;&lt;li&gt;the recent password entry that sensitive actions require.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each job needs a new owner. The options I found:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Connected sign-in takes every fallback role. This one always applies. A user who loses their last local factor while offline has no offline access until they reconnect.&lt;/li&gt;&#10;&lt;li&gt;A mandatory app passcode becomes the baseline factor. Set it during connected provisioning and wrap the ADK with it like the password (KDF, then device key). Three PIN rules change. Six digits stop being enough, because nothing stands behind the passcode: at 0.5 seconds per guess on a compromised device, 10^6 candidates take under six days. Allow alphanumeric passcodes with a real minimum length. The threshold sends the user to connected sign-in, and choosing a new passcode needs one too. The user gets a second secret to remember; that is the price of an offline knowledge factor.&lt;/li&gt;&#10;&lt;li&gt;The device passcode can back up the biometric wrapper. It avoids losing offline access at the first biometric change and reopens the &amp;ldquo;watched the owner type it&amp;rdquo; risk. Make it explicit, let administrators forbid it, and check on a device whether iOS still releases a &lt;code&gt;biometryCurrentSet&lt;/code&gt; or &lt;code&gt;devicePasscode&lt;/code&gt; item through the passcode after enrollment changes.&lt;/li&gt;&#10;&lt;li&gt;A recovery key can have enough entropy that a copied wrapper is safe even without device binding. Users lose these, and nobody can reset one, so offer it only where a lost factor can cost data.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;What does not work is anything the identity provider issued. A cached refresh token or ID token proves the device holds a token, not that the user is holding the device. Don&amp;rsquo;t wrap the ADK under it, and delete the identity provider&amp;rsquo;s token cache after the server exchange.&lt;/p&gt;&#10;&lt;p&gt;Check one thing in your own protocol. If the server opens a passwordless user&amp;rsquo;s data without any secret from the user, it can provision the ADK again at the next sign-in. A lost factor then costs offline access, not data, as long as offline records stay read-only. Unsynced offline edits are the exception, because they exist only on the device.&lt;/p&gt;&#10;&lt;p&gt;Decide the factor set at provisioning from the login method, and store it with the policy. If the same user later provisions through a password login, re-enroll; don&amp;rsquo;t bolt a password wrapper onto a passwordless enrollment.&lt;/p&gt;&#10;&#10;&lt;h2 id="caveats"&gt;Caveats &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The ADK&amp;rsquo;s scope is a protocol decision. If the ADK only protects this device&amp;rsquo;s cache, logout and revocation destroy it. If it is the user&amp;rsquo;s end-to-end key shared with the server and other devices, a device revoked while offline keeps a copy that decrypts future data, and revoking it means rotating the key.&lt;/li&gt;&#10;&lt;li&gt;Offline edits are a separate project. Ship read-only offline records first. Editable mode needs an encrypted operation log separate from server-confirmed state, conflict rules that keep both versions when a merge could lose data, and an authorization recheck for each operation on reconnect.&lt;/li&gt;&#10;&lt;li&gt;On rooted and jailbroken devices, hardware-backed keys and integrity signals raise the cost. Retry counters and expiry are app logic and don&amp;rsquo;t bind an attacker who runs their own code against the keys. Don&amp;rsquo;t claim otherwise.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="test-the-failure-paths"&gt;Test the failure paths &lt;/h2&gt;&#10;&lt;p&gt;Happy-path testing says nothing about whether keys and plaintext stay contained. The release gates I would not skip:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Area&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Test&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Provisioning&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A never-enrolled device fails offline; restoring a backup to another device, or an older backup to the same one, forces a check-in&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Crypto&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Copied password and PIN wrappers cannot be opened off the device; ciphertext swapped between records or between fields of one record fails authentication&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Biometrics&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add and remove enrolled biometrics, change and remove the passcode, reboot, process death&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retries&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Counters survive relaunch and reboot; the PIN threshold removes only the PIN wrapper&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Freshness&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clock forward and back, time zone change, reboot, a long sleep counted toward expiry, a replayed older record, the exact boundary; a stale device never prompts&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documents&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Truncated, reordered, and dropped chunks fail; kill the app mid-preview and confirm the temporary is gone before the next unlock&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Deletion&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Uninstall and reinstall leave no usable Keychain state; removing one user leaves another intact&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Autofill origin&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A look-alike package name gets no match; &lt;code&gt;victim.github.io&lt;/code&gt; is not offered on &lt;code&gt;attacker.github.io&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS&lt;/a&gt;&#10; and &lt;a&#10; href="https://mas.owasp.org/MASTG"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG&lt;/a&gt;&#10;, &lt;a&#10; href="https://pages.nist.gov/800-63-4/sp800-63b.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;NIST SP 800-63B&lt;/a&gt;&#10;, &lt;a&#10; href="https://csrc.nist.gov/pubs/sp/800/132/final"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;NIST SP 800-132&lt;/a&gt;&#10; for PBKDF2, and &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9106"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RFC 9106&lt;/a&gt;&#10; for Argon2id set the baseline. They don&amp;rsquo;t decide where your keys live or what happens when a preview crashes.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Offline, the app can only answer &amp;ldquo;is this the enrolled user, on the enrolled device, within the allowed window?&amp;rdquo; Every unlock path, including the password, should require the device&amp;rsquo;s hardware key; every biometric prompt should gate a key operation; and the window should be measured by a clock that keeps counting while the phone sleeps.&lt;/p&gt;&#10;&lt;p&gt;If your app already has an offline mode, copy its sandbox to another machine and try to open the password wrapper there. If that attempt gets as far as a KDF, the password path is missing its device key.&lt;/p&gt;&#10;</description></item><item><title>Backing up ~/.claude and ~/.codex without archiving your AI logins</title><link>https://eghuro.cz/posts/ai-client-local-state-backup/</link><pubDate>Sat, 31 Oct 2026 22:29:00 +0100</pubDate><guid>https://eghuro.cz/posts/ai-client-local-state-backup/</guid><description>&lt;p&gt;If you back up the whole &lt;code&gt;~/.claude&lt;/code&gt; or &lt;code&gt;~/.codex&lt;/code&gt; folder, the restore works, and the backup now holds everything an infostealer wants. The same folder holds your settings, every session transcript in plaintext, and, on Linux, the login itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;~/.claude/.credentials.json # Claude Code login, mode 0600&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;~/.codex/auth.json # Codex login when file storage is in use&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;~/.claude/projects/*/*.jsonl # transcripts: prompts, tool output, file contents&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Split the folder into two lists. Back up what you wrote: settings, instructions, skills, hooks, and memory. Keep transcripts as optional encrypted data. Leave login files out of routine backups and log in again after a restore.&lt;/p&gt;&#10;&#10;&lt;h2 id="where-ai-client-state-lives-on-macos-and-linux"&gt;Where AI client state lives on macOS and Linux &lt;/h2&gt;&#10;&lt;p&gt;I checked these paths against Claude Code 2.1.316, Claude Desktop for Linux 4.4212.0, ChatGPT for macOS 1.2026.183, the Codex desktop app for macOS 26.1027.30824, and Codex source at commit &lt;code&gt;67f0994&lt;/code&gt;. &amp;ldquo;Documented&amp;rdquo; means the vendor docs name the path. &amp;ldquo;Observed&amp;rdquo; means I found it by inspecting the official build so that it can change in any release.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Client&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;State root&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Login material&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Evidence&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude Code&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/.claude/&lt;/code&gt; and &lt;code&gt;~/.claude.json&lt;/code&gt;, both under &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; if set&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Keychain on macOS, or &lt;code&gt;~/.claude/.credentials.json&lt;/code&gt; when the Keychain rejects the write (locked in an SSH session, for example). On Linux, always that file&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documented&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude Desktop&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;macOS: &lt;code&gt;~/Library/Application Support/Claude/&lt;/code&gt;. Linux (beta): &lt;code&gt;${XDG_CONFIG_HOME:-~/.config}/Claude/&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Electron cookies and Local Storage, extension secrets encrypted with &lt;code&gt;safeStorage&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;MCP config documented, rest observed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ChatGPT desktop, macOS only&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/Library/Application Support/com.openai.chat/&lt;/code&gt;, &lt;code&gt;~/Library/Group Containers/2DC432GLL2.group.com.openai.chat/&lt;/code&gt;, &lt;code&gt;~/Library/Preferences/com.openai.chat.plist&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Keychain items in OpenAI&amp;rsquo;s access group&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Observed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Codex desktop, macOS only&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/Library/Application Support/Codex/&lt;/code&gt;, plus &lt;code&gt;~/.codex&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Shared with Codex CLI&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Observed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Codex CLI&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;${CODEX_HOME:-~/.codex}&lt;/code&gt;. &lt;code&gt;CODEX_SQLITE_HOME&lt;/code&gt;, &lt;code&gt;sqlite_home&lt;/code&gt; and &lt;code&gt;log_dir&lt;/code&gt; can move parts of it&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;auth.json&lt;/code&gt; in &lt;code&gt;file&lt;/code&gt; mode or as the &lt;code&gt;auto&lt;/code&gt; fallback; &lt;code&gt;.credentials.json&lt;/code&gt; for MCP OAuth without a keyring&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documented and source&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Desktop sessions that run Claude Code write to &lt;code&gt;~/.claude/&lt;/code&gt;, not to the Electron folder, so include both.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-to-back-up-and-what-to-leave-out"&gt;What to back up and what to leave out &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Treatment&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Claude Code&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Codex&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Back up, versioned&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;settings.json&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;rules/&lt;/code&gt;, &lt;code&gt;skills/&lt;/code&gt;, &lt;code&gt;commands/&lt;/code&gt;, &lt;code&gt;agents/&lt;/code&gt;, &lt;code&gt;workflows/&lt;/code&gt;, &lt;code&gt;output-styles/&lt;/code&gt;, &lt;code&gt;keybindings.json&lt;/code&gt;, &lt;code&gt;projects/*/memory/&lt;/code&gt;, &lt;code&gt;agent-memory/&lt;/code&gt;, &lt;code&gt;plugins/installed_plugins.json&lt;/code&gt; as a manifest&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;config.toml&lt;/code&gt;, &lt;code&gt;*.config.toml&lt;/code&gt;, &lt;code&gt;hooks.json&lt;/code&gt;, &lt;code&gt;rules/&lt;/code&gt;, &lt;code&gt;skills/&lt;/code&gt;, agent-role files&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Back up after a secret check&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;~/.claude.json&lt;/code&gt; (personal MCP servers, trust decisions)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;MCP &lt;code&gt;env&lt;/code&gt; blocks in &lt;code&gt;config.toml&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Optional, encrypted, short retention&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;projects/*/*.jsonl&lt;/code&gt;, &lt;code&gt;subagents/&lt;/code&gt;, &lt;code&gt;tool-results/&lt;/code&gt;, &lt;code&gt;history.jsonl&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;sessions/YYYY/MM/DD/rollout-*.jsonl&lt;/code&gt;, &lt;code&gt;archived_sessions/&lt;/code&gt;, &lt;code&gt;history.jsonl&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Exclude&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;.credentials.json&lt;/code&gt;, &lt;code&gt;file-history/&lt;/code&gt;, &lt;code&gt;paste-cache/&lt;/code&gt;, &lt;code&gt;debug/&lt;/code&gt;, &lt;code&gt;shell-snapshots/&lt;/code&gt;, &lt;code&gt;session-env/&lt;/code&gt;, &lt;code&gt;backups/&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;auth.json&lt;/code&gt;, &lt;code&gt;.credentials.json&lt;/code&gt;, &lt;code&gt;log/&lt;/code&gt;, &lt;code&gt;logs_2.sqlite&lt;/code&gt;, &lt;code&gt;shell_snapshots/&lt;/code&gt;, &lt;code&gt;packages/&lt;/code&gt;, caches&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;For Claude Desktop, keep &lt;code&gt;claude_desktop_config.json&lt;/code&gt; after checking its MCP &lt;code&gt;env&lt;/code&gt; blocks, plus a list of installed extensions and where each came from. Reinstall extensions instead of restoring their executable caches. The ChatGPT app&amp;rsquo;s local databases are caches of a server-side account; use the account&amp;rsquo;s data export for conversations you want to keep.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-copying-the-whole-folder-is-the-wrong-default"&gt;Why copying the whole folder is the wrong default &lt;/h2&gt;&#10;&lt;p&gt;Transcripts are plaintext. &lt;a&#10; href="https://code.claude.com/docs/en/claude-directory.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Anthropic&amp;rsquo;s docs&lt;/a&gt;&#10; say that anything passing through a tool is written to the session &lt;code&gt;.jsonl&lt;/code&gt;, so a &lt;code&gt;.env&lt;/code&gt; file a tool reads or a token a command prints is in there. Codex rollouts record tool activity the same way.&lt;/p&gt;&#10;&lt;p&gt;Your backup also keeps transcripts after the client has deleted them. Claude Code deletes transcripts after &lt;code&gt;cleanupPeriodDays&lt;/code&gt; (default 30), but keeps sessions started, or last continued in Claude Desktop or Cowork at any age unless you set &lt;code&gt;desktopSessionCleanupPeriodDays&lt;/code&gt;. A backup with a year of history still holds a transcript eleven months after the client deleted it.&lt;/p&gt;&#10;&lt;p&gt;A restore can also run code. Hooks, MCP server commands, skills, plugins, desktop extensions, and shell snapshots all execute something. If malware edited them, a full restore puts it back on the clean machine.&lt;/p&gt;&#10;&lt;p&gt;The Keychain protects less than it seems to. It stops someone who copies the disk offline, but malware running as your user can still request the item. The Linux desktop build can also fall back to Chromium&amp;rsquo;s basic password store when no wallet is running.&lt;/p&gt;&#10;&#10;&lt;h2 id="dead-ends-worth-skipping"&gt;Dead ends worth skipping &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Guessing Linux paths from macOS ones.&lt;/strong&gt; Replacing &lt;code&gt;Library/Application Support&lt;/code&gt; with &lt;code&gt;.config&lt;/code&gt; works for Claude Desktop, which has a Linux beta. OpenAI ships no Linux desktop app for ChatGPT or Codex. The Codex source has Linux branches, but there is no supported Linux build. On Linux, your ChatGPT state lives in the browser profile.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Copying only the &lt;code&gt;.sqlite&lt;/code&gt; files.&lt;/strong&gt; Codex keeps state in &lt;code&gt;state_5.sqlite&lt;/code&gt;, &lt;code&gt;thread_history_1.sqlite&lt;/code&gt;, &lt;code&gt;memories_1.sqlite&lt;/code&gt; and &lt;a&#10; href="https://github.com/openai/codex/blob/67f0994b532b8740f14b5b738af7b7b2a915b1d6/codex-rs/state/src/sqlite.rs#L48-L53"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;others&lt;/a&gt;&#10;. A live copy without the &lt;code&gt;-wal&lt;/code&gt; and &lt;code&gt;-shm&lt;/code&gt; sidecars can be incomplete or corrupt. Stop the client and copy all three.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Treating the desktop database as the archive.&lt;/strong&gt; The Codex desktop app can rebuild its index from the rollout JSONL files, but metadata that lives only in the database is lost, back up the JSONL files.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Assuming &lt;code&gt;claude purge&lt;/code&gt; cleaned everything.&lt;/strong&gt; It doesn&amp;rsquo;t touch &lt;code&gt;shell-snapshots/&lt;/code&gt;, &lt;code&gt;backups/&lt;/code&gt; (older copies of &lt;code&gt;~/.claude.json&lt;/code&gt;) or files written by &lt;code&gt;/heapdump&lt;/code&gt;. According to Anthropic, a heap snapshot contains the full conversation and any credentials the process held.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="restore-into-a-staging-profile"&gt;Restore into a staging profile &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Reinstall the signed apps, plugins, and extensions from their sources.&lt;/li&gt;&#10;&lt;li&gt;Restore your config, then read every hook, MCP command, and executable path before the first launch.&lt;/li&gt;&#10;&lt;li&gt;Restore transcripts or memory only if you need them.&lt;/li&gt;&#10;&lt;li&gt;Log in again. Don&amp;rsquo;t restore &lt;code&gt;auth.json&lt;/code&gt;, &lt;code&gt;.credentials.json&lt;/code&gt;, cookies, or Keychain exports.&lt;/li&gt;&#10;&lt;li&gt;Resume one session and confirm that it uses the right account and repository.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&#10;&lt;h2 id="verify-the-backup-set"&gt;Verify the backup set &lt;/h2&gt;&#10;&lt;p&gt;To list what exists and how large it is, without printing any contents, run this on the machine you back up:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; p in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CONFIG_DIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.claude&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude.json&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CODEX_HOME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.codex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;XDG_CONFIG_HOME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.config&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/Claude&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/Claude&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/Codex&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/com.openai.chat&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Group Containers/2DC432GLL2.group.com.openai.chat&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;[&lt;/span&gt; -e &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; du -sh &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then check that no login file ended up in the backup:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;find /path/to/mounted/backup &lt;span class="se"&gt;\(&lt;/span&gt; -name .credentials.json -o -name auth.json &lt;span class="se"&gt;\)&lt;/span&gt; -print&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It should print nothing. If it prints anything, that backup is a credential store: encrypt it, shorten its retention, or rotate the logins.&lt;/p&gt;&#10;&#10;&lt;h2 id="if-malware-may-already-have-run"&gt;If malware may already have run &lt;/h2&gt;&#10;&lt;p&gt;Don&amp;rsquo;t treat a restore as the cleanup. From a clean device, revoke your Claude and ChatGPT sessions, then rotate API keys, MCP and connector credentials, and any secret that appeared in a transcript or in tool output. Any backup the malware could reach counts as exposed. Rebuild the user profile, restore only config you have reviewed, and log in again.&lt;/p&gt;&#10;&#10;&lt;h2 id="caveats"&gt;Caveats &lt;/h2&gt;&#10;&lt;p&gt;Paths and retention rules change between releases. The ChatGPT and desktop paths come from build inspection, not from a published schema, so check them against your installed versions before you write a backup job. Sources: the &lt;a&#10; href="https://code.claude.com/docs/en/claude-directory.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;.claude&lt;/code&gt; directory reference&lt;/a&gt;&#10;, &lt;a&#10; href="https://code.claude.com/docs/en/authentication.md#credential-management"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Claude Code credential management&lt;/a&gt;&#10;, &lt;a&#10; href="https://learn.chatgpt.com/docs/config-file/config-advanced.md#config-and-state-locations"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Codex config and state locations&lt;/a&gt;&#10;, &lt;a&#10; href="https://learn.chatgpt.com/docs/auth.md#credential-storage"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Codex credential storage&lt;/a&gt;&#10;, &lt;a&#10; href="https://modelcontextprotocol.io/quickstart/user"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Claude Desktop MCP config paths&lt;/a&gt;&#10;, and &lt;a&#10; href="https://www.electronjs.org/docs/latest/api/app#appgetpathname"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Electron&amp;rsquo;s &lt;code&gt;app.getPath()&lt;/code&gt;&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&lt;p&gt;Back up the config you wrote, and leave out the files the app uses for login. Run the &lt;code&gt;find&lt;/code&gt; check above against your latest backup tonight.&lt;/p&gt;&#10;</description></item><item><title>Swift Testing withKnownIssue can hide snapshot failures</title><link>https://eghuro.cz/posts/swift-testing-withknownissue-snapshot-failures/</link><pubDate>Fri, 30 Oct 2026 19:45:00 +0100</pubDate><guid>https://eghuro.cz/posts/swift-testing-withknownissue-snapshot-failures/</guid><description>&lt;p&gt;Two snapshot tests passed while reporting:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;No reference was found on disk. Automatically recorded snapshot:&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Their Swift Testing &lt;code&gt;withKnownIssue&lt;/code&gt; wrapper accepted every issue inside the snapshot assertion, including missing references. Adding a &lt;code&gt;matching:&lt;/code&gt; closure limited the exception to expected dependency reports. A deliberate wrong-state snapshot then failed, even though those reports remained known issues.&lt;/p&gt;&#10;&#10;&lt;h2 id="restrict-which-issues-count-as-known"&gt;Restrict which issues count as known &lt;/h2&gt;&#10;&lt;p&gt;The view had two states: a progress indicator before a check completed, and content afterward. Rendering the content also touched an unimplemented test dependency. The snapshot helper wrapped rendering and comparison in &lt;code&gt;withKnownIssue(isIntermittent: true)&lt;/code&gt; to tolerate that report.&lt;/p&gt;&#10;&lt;p&gt;Without a matcher, the wrapper also accepted issues from the assertion it surrounded. The initial recording run exited successfully: two tests passed with three known issues. Two of those issues said references were missing; only one was the dependency report the wrapper was meant to tolerate.&lt;/p&gt;&#10;&lt;p&gt;The historical fix added this predicate:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;describing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="bp"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Unimplemented&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here is the same policy as a generalized synchronous helper. Pass an existing, nonthrowing snapshot assertion as its body:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Testing&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;withExpectedDependencyIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;withKnownIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isIntermittent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;matching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;describing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="bp"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Unimplemented&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The public &lt;a&#10; href="https://developer.apple.com/documentation/testing/withknownissue%28_:isintermittent:sourcelocation:_:when:matching:%29"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;withKnownIssue&lt;/code&gt; matching overload&lt;/a&gt;&#10; takes a predicate over &lt;code&gt;Issue&lt;/code&gt;. Returning &lt;code&gt;false&lt;/code&gt; leaves an issue outside this exception. The synchronous overload is &lt;code&gt;rethrows&lt;/code&gt;; this helper needs no &lt;code&gt;try&lt;/code&gt; because its body is nonthrowing. A throwing assertion needs corresponding throwing handling.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;isIntermittent: true&lt;/code&gt; allows the body to record no matching issue. That was useful for the progress state, which did not touch the dependency. It does not restrict which issues match. Without &lt;code&gt;matching:&lt;/code&gt;, an intermittent wrapper still accepts everything recorded in its scope.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-mutation-must-change-the-rendered-output"&gt;A mutation must change the rendered output &lt;/h2&gt;&#10;&lt;p&gt;The first attempted verification changed a prefilled field. Both snapshot tests still passed. That was an ineffective mutation for this check; it did not establish that the assertion could detect the intended visual regression.&lt;/p&gt;&#10;&lt;p&gt;The stronger mutation completed the check before constructing the view in the test for the &lt;em&gt;before-check&lt;/em&gt; reference. It deliberately compared the content state against the progress-state baseline.&lt;/p&gt;&#10;&lt;p&gt;That run exited with code 65. The before-check test failed, while the after-check test passed with its expected known issue. Across the two tests, the runner reported three issues, including two known issues. Restoring the original setup produced a successful run of 239 tests in 24 suites with one known issue.&lt;/p&gt;&#10;&lt;p&gt;This shows that the narrowed helper rejected an incorrectly rendered state. The session did not run that same wrong-state mutation against the original broad wrapper, so it does not provide an identical before-and-after mutation comparison. The missing-reference reports already demonstrate the broad wrapper&amp;rsquo;s excessive scope.&lt;/p&gt;&#10;&#10;&lt;h2 id="keep-the-exception-smaller-than-the-assertion"&gt;Keep the exception smaller than the assertion &lt;/h2&gt;&#10;&lt;p&gt;The repair used here matched a description substring. It remains brittle: another report containing &lt;code&gt;Unimplemented&lt;/code&gt; would also match, and diagnostic wording can change. Where the reporting library exposes a stable public discriminator, prefer that over a broad text search. Better still, provide the missing test dependency so the assertion needs no exception.&lt;/p&gt;&#10;&lt;p&gt;Keep that follow-up separate from what was verified. Other snapshot helpers retained broad wrappers; this change did not repair them. A passing suite using this helper says nothing about their failure detection.&lt;/p&gt;&#10;&lt;p&gt;These results came from the original Xcode simulator runs. The generalized helper follows &lt;a&#10; href="https://github.com/swiftlang/swift-testing/blob/main/Sources/Testing/Issues/KnownIssue.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Swift Testing&amp;rsquo;s public matching overload&lt;/a&gt;&#10;; it has not been compiled separately.&lt;/p&gt;&#10;&lt;p&gt;Before trusting a snapshot helper with a known-issue scope, force a visibly different state against an existing reference and require a failing run. Restore the state and require green again.&lt;/p&gt;&#10;</description></item><item><title>A shared Swift timeout left an earlier task without a deadline</title><link>https://eghuro.cz/posts/swift-concurrency-shared-timeout/</link><pubDate>Fri, 30 Oct 2026 17:37:00 +0100</pubDate><guid>https://eghuro.cz/posts/swift-concurrency-shared-timeout/</guid><description>&lt;p&gt;A Swift service had a timeout, but waiting for its in-flight work could still hang. A held-fetch regression test reached Swift Testing&amp;rsquo;s outer limit:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Time limit was exceeded: 60.000 seconds&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Starting another check replaced the shared timer while the earlier fetch kept running. The fix was to give each tracked operation its own cancellation deadline and cancel that deadline when the operation ended. That still depends on the fetch cooperating with cancellation.&lt;/p&gt;&#10;&#10;&lt;h2 id="one-timer-could-not-cover-overlapping-fetches"&gt;One timer could not cover overlapping fetches &lt;/h2&gt;&#10;&lt;p&gt;The service could start another asynchronous check before the previous one finished. A separate tracker retained the in-flight tasks so a caller could wait for their completion. Its waiter took a snapshot of those tasks and awaited their results.&lt;/p&gt;&#10;&lt;p&gt;The mistaken assumption was that the service&amp;rsquo;s existing timeout covered everything in that snapshot. Review found that starting a check reset the shared timer without canceling the previous fetch. Finishing a check also reset the timer so that an older completion could interfere with a newer check&amp;rsquo;s deadline.&lt;/p&gt;&#10;&lt;p&gt;An illustrative sequence makes the ownership mismatch visible:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start A tracked: A shared timer: A&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start B tracked: A, B shared timer: B; A&amp;#39;s timer reset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;finish B tracked: A shared timer: reset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wait for A A still running no cancellation deadline for A&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The tracker remembered more work than the timer covered. Making that timer longer would leave the same mismatch.&lt;/p&gt;&#10;&#10;&lt;h2 id="put-the-deadline-beside-the-operation"&gt;Put the deadline beside the operation &lt;/h2&gt;&#10;&lt;p&gt;The correction belonged where the tracker created each fetch task. That scope already knew which task it owned and when it finished.&lt;/p&gt;&#10;&lt;p&gt;For each operation, create a separate deadline task that sleeps for the configured duration, then calls &lt;code&gt;cancel()&lt;/code&gt; on that operation&amp;rsquo;s task handle. Keep the deadline handle local. On exit, cancel the deadline and remove the operation from tracking. Forward cancellation from the caller to the fetch as well.&lt;/p&gt;&#10;&lt;p&gt;The resulting ownership is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;operation A owns deadline A; completion A cancels deadline A&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;operation B owns deadline B; completion B cancels deadline B&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Canceling a deadline&amp;rsquo;s sleep must end the deadline task. Swallowing the sleep&amp;rsquo;s cancellation error and continuing to &lt;code&gt;cancel()&lt;/code&gt; would turn normal cleanup into an unintended cancellation request.&lt;/p&gt;&#10;&lt;p&gt;Keeping the handles local also means A&amp;rsquo;s cleanup cannot accidentally cancel B&amp;rsquo;s deadline through a shared slot.&lt;/p&gt;&#10;&#10;&lt;h2 id="prove-that-the-waiter-needs-this-deadline"&gt;Prove that the waiter needs this deadline &lt;/h2&gt;&#10;&lt;p&gt;The regression test used a fetch held until cancellation, a short injected operation timeout, and a signal confirming that the fetch had started. Nothing outside the tracker canceled it. The test then awaited completion and checked that the fetch ended before the waiting caller continued.&lt;/p&gt;&#10;&lt;p&gt;Before the implementation used the injected timeout, the test failed at the one-minute limit. After adding the operation-local deadline, the archived test output reports the held-fetch test passing and the focused test target exiting successfully.&lt;/p&gt;&#10;&lt;p&gt;That establishes the missing-deadline behavior for a cooperative fake. It does not reproduce the entire overlapping-check sequence live. For your own tracker, also hold A open while B starts and finishes, then confirm that A still receives its own cancellation request.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-cancellation-deadline-is-not-a-hard-timeout"&gt;A cancellation deadline is not a hard timeout &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://developer.apple.com/documentation/swift/task/cancel%28%29"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Swift&amp;rsquo;s &lt;code&gt;Task.cancel()&lt;/code&gt; documentation&lt;/a&gt;&#10; explicitly says cancellation is cooperative. Calling it flags the task and runs cancellation handlers; arbitrary work does not automatically stop. A fetch that ignores cancellation can outlive this deadline and keep a completion waiter suspended.&lt;/p&gt;&#10;&lt;p&gt;Later work therefore separated the operation&amp;rsquo;s cancellation deadline from the caller&amp;rsquo;s user-facing wait budget. It also added recovery after an unsuccessful check. Giving every operation a deadline did not settle how long a caller should wait or whether continuing after failure was safe.&lt;/p&gt;&#10;&lt;p&gt;Before relying on a service timeout, start two operations and finish the newer one first. Check that the older operation still has its own cancellation deadline.&lt;/p&gt;&#10;</description></item><item><title>SwiftUI large title disappears after tapping the selected tab again</title><link>https://eghuro.cz/posts/swiftui-large-title-global-appearance/</link><pubDate>Fri, 30 Oct 2026 14:38:00 +0100</pubDate><guid>https://eghuro.cz/posts/swiftui-large-title-global-appearance/</guid><description>&lt;p&gt;The Settings large title disappeared after tapping the already-selected tab again. Its space stayed empty; the small title still appeared when scrolling, and the accessibility check passed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;titleBefore=true titleAfter=true&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An opaque navigation-bar background was painting over the title. The eventual repair removed a screen modifier&amp;rsquo;s writes to &lt;code&gt;UINavigationBar.appearance()&lt;/code&gt;. Hiding the affected screen&amp;rsquo;s bar background was an intermediate workaround that introduced a font regression.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-title-still-existed"&gt;The title still existed &lt;/h2&gt;&#10;&lt;p&gt;The prototype varied the tab view, loading state, scroll-view wrapper, and swipe-back helper. Its accessibility existence checks stayed green. Adding the app&amp;rsquo;s opaque navigation-bar appearance also produced green checks, even though the title vanished visually.&lt;/p&gt;&#10;&lt;p&gt;Counting dark pixels in the prototype&amp;rsquo;s title region exposed the difference:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Appearance&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;After first tap&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;After re-tap&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Default&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Opaque&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Those are diagnostic counts from one fixed light-background rendering, not a portable visibility threshold. They contradicted the existence assertion directly.&lt;/p&gt;&#10;&lt;p&gt;Before-and-after hierarchy dumps from the running app showed the same title view moving from the navigation bar into the scroll view. The bar&amp;rsquo;s opaque background then covered it. Initially, the screen displayed a loader without a scroll view, so UIKit built the title inside the bar. Re-tapping the selected tab triggered the transition that exposed the problem on the investigated simulator.&lt;/p&gt;&#10;&lt;p&gt;This establishes a rendering mechanism in that environment. It does not establish identical title placement on every supported runtime.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-local-modifier-changed-later-navigation-bars"&gt;A local modifier changed later navigation bars &lt;/h2&gt;&#10;&lt;p&gt;The offending modifier intended to remove a divider and set one screen&amp;rsquo;s background. Its essential mistake looked like this generalized fragment inside a SwiftUI &lt;code&gt;.onAppear&lt;/code&gt; closure:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UINavigationBarAppearance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configureWithOpaqueBackground&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backgroundColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secondarySystemBackground&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shadowColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UINavigationBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;standardAppearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appearance&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UINavigationBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;scrollEdgeAppearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appearance&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The closure belongs to a screen; the &lt;a&#10; href="https://developer.apple.com/documentation/uikit/uiappearance"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;appearance proxy&lt;/a&gt;&#10; configures all instances of that class. UIKit applies appearance changes when a view enters a window. A different tab&amp;rsquo;s later navigation bar can inherit those settings, so screen order becomes part of the bug&amp;rsquo;s setup.&lt;/p&gt;&#10;&lt;p&gt;The repair deleted this global writer. The calling screens already declared their backgrounds through SwiftUI&amp;rsquo;s per-screen toolbar styling; the investigated runtimes did not need a replacement divider-hiding implementation. Their existing snapshots passed without re-recording. Appearance save-and-restore workarounds around a system-presented screen went away with the leak.&lt;/p&gt;&#10;&lt;p&gt;A focused test supplied stronger evidence than the original title lookup: show a styled screen, then create another navigation bar and check its background. It failed before the deletion because the next bar inherited a background, then passed afterward. Separate checks guarded the calling screen&amp;rsquo;s color and the next large title&amp;rsquo;s font.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-one-line-hotfix-was-reversed"&gt;Why the one-line hotfix was reversed &lt;/h2&gt;&#10;&lt;p&gt;The initial workaround used &lt;code&gt;.toolbarBackground(.hidden, for: .navigationBar)&lt;/code&gt; on Settings. It kept the prototype&amp;rsquo;s title visible under the opaque appearance.&lt;/p&gt;&#10;&lt;p&gt;The attempted app snapshot never reproduced the original disappearance. The real screen reloaded into a spinner first, unlike the prototype&amp;rsquo;s scroll-view-first case. Capturing the loaded window still showed the title without the fix. We dropped that test; it couldn&amp;rsquo;t serve as a regression guard.&lt;/p&gt;&#10;&lt;p&gt;Later checks found that hiding the background also made the large title use the system font instead of the intended custom font. A snapshot cropped to the bar failed with that modifier present and passed after its removal. Once the global opaque write was gone, the workaround no longer had a reason to stay.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-the-interaction-not-just-the-label"&gt;Verify the interaction, not just the label &lt;/h2&gt;&#10;&lt;p&gt;Builds and broad test plans passed after the cleanup. Final manual checks in the running app remained open, and older supported runtimes were untested. The font snapshot and next-bar test do not close that gap.&lt;/p&gt;&#10;&lt;p&gt;For release verification:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Visit the screens that previously wrote global appearance, then open Settings. Repeat with a different screen order.&lt;/li&gt;&#10;&lt;li&gt;Tap the selected Settings tab again. Capture the title before and after, then scroll through its large-to-inline transition.&lt;/li&gt;&#10;&lt;li&gt;Check actual title pixels and contrast against the background. Use the hierarchy to explain occlusion; accessibility existence, or &lt;code&gt;isHittable&lt;/code&gt; alone, is not universal proof that text is painted visibly.&lt;/li&gt;&#10;&lt;li&gt;Repeat on supported runtimes, checking pushed screens&amp;rsquo; bar styling too.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Audit screen-level &lt;code&gt;UINavigationBar.appearance()&lt;/code&gt; writes before adding another visibility workaround.&lt;/p&gt;&#10;</description></item><item><title>Xcode Archive can ship the wrong display name despite correct source settings</title><link>https://eghuro.cz/posts/xcode-archive-processed-plist-identity/</link><pubDate>Thu, 29 Oct 2026 14:22:00 +0100</pubDate><guid>https://eghuro.cz/posts/xcode-archive-processed-plist-identity/</guid><description>&lt;p&gt;A production iOS archive had the production bundle ID and backend environment, but its &lt;code&gt;CFBundleDisplayName&lt;/code&gt; was the beta name. The tagged source said otherwise, and App Store Connect never shows that name, so only an installed build revealed it. Add a Run Script phase that checks the processed app &lt;code&gt;Info.plist&lt;/code&gt;, so a manual Product &amp;gt; Archive also rejects the wrong identity.&lt;/p&gt;&#10;&lt;p&gt;Using synthetic names, the mismatch was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CFBundleIdentifier = com.example.notebook&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CFBundleDisplayName = NotebookBeta&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="source-checks-missed-the-artifact"&gt;Source checks missed the artifact &lt;/h2&gt;&#10;&lt;p&gt;The investigation initially ruled out the mismatch from project settings and runtime configuration checks. That conclusion was too strong. The runtime check validated the product variant against the backend environment; it did not validate the display name. A production bundle with a beta display name could satisfy it.&lt;/p&gt;&#10;&lt;p&gt;Reading the archived app&amp;rsquo;s plist settled the question. Its name disagreed with the committed production settings. An uncommitted local edit was the likely explanation, but the archive alone could not establish exactly how the value changed.&lt;/p&gt;&#10;&lt;p&gt;The release flow had no step that showed the name either. App Store Connect metadata covers the store listing&amp;rsquo;s app name, not the bundle&amp;rsquo;s &lt;code&gt;CFBundleDisplayName&lt;/code&gt;, which only appears under the home screen icon. The only check is to install the production build from TestFlight, or from the App Store after release, and read that label by hand. It is easy to overlook when the bundle ID and backend already look right.&lt;/p&gt;&#10;&lt;p&gt;Source-level regression tests still help, but manual Archive does not run the scheme&amp;rsquo;s Test action. A check confined to a deployment lane also misses someone archiving directly in Xcode.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-the-processed-plist-during-the-build"&gt;Check the processed plist during the build &lt;/h2&gt;&#10;&lt;p&gt;Add a Run Script phase named &lt;code&gt;Verify App Identity&lt;/code&gt; to the app target, after its existing phases. Use &lt;code&gt;/bin/sh&lt;/code&gt;. Leave &lt;strong&gt;Run script only when installing&lt;/strong&gt; unchecked.&lt;/p&gt;&#10;&lt;p&gt;Declare this Input File:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Declare this Output File:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$(DERIVED_FILE_DIR)/verified-app-identity.stamp&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Uncheck &lt;strong&gt;Based on dependency analysis&lt;/strong&gt; for this small guard. It should validate every build, including repeated archives. The input declaration still establishes the dependency on plist processing; visual phase order alone is insufficient. &lt;a&#10; href="https://developer.apple.com/documentation/xcode/running-custom-scripts-during-a-build"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple&amp;rsquo;s Run Script documentation&lt;/a&gt;&#10; explains how declared inputs and outputs determine task ordering.&lt;/p&gt;&#10;&lt;p&gt;Paste this script, adapting the explicit configuration allowlist:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_OUTPUT_FILE_0&lt;/span&gt;&lt;span class="p"&gt;:?Declare the stamp Output File&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;stamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_OUTPUT_FILE_0&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET_BUILD_DIR&lt;/span&gt;&lt;span class="p"&gt;:?&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INFOPLIST_PATH&lt;/span&gt;&lt;span class="p"&gt;:?&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONFIGURATION&lt;/span&gt;&lt;span class="p"&gt;:?&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;plist&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_BUILD_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$INFOPLIST_PATH&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fail&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;error: %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIGURATION&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; in&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Release&lt;span class="o"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;com.example.notebook&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Notebook&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Debug&lt;span class="o"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;com.example.notebook.beta&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NotebookBeta&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; *&lt;span class="o"&gt;)&lt;/span&gt; fail &lt;span class="s2"&gt;&amp;#34;No identity policy for configuration &lt;/span&gt;&lt;span class="nv"&gt;$CONFIGURATION&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;esac&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;&amp;#34;Missing processed Info.plist: &lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;/usr/libexec/PlistBuddy -c &lt;span class="s1"&gt;&amp;#39;Print :CFBundleIdentifier&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; fail &lt;span class="s1"&gt;&amp;#39;Cannot read CFBundleIdentifier&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;/usr/libexec/PlistBuddy -c &lt;span class="s1"&gt;&amp;#39;Print :CFBundleDisplayName&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; fail &lt;span class="s1"&gt;&amp;#39;Cannot read CFBundleDisplayName&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$expected_id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$expected_name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; fail &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIGURATION&lt;/span&gt;&lt;span class="s2"&gt; requires &lt;/span&gt;&lt;span class="nv"&gt;$expected_id&lt;/span&gt;&lt;span class="s2"&gt; / &lt;/span&gt;&lt;span class="nv"&gt;$expected_name&lt;/span&gt;&lt;span class="s2"&gt;; got &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; / &lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Verified app identity: %s / %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Do not substitute the source &lt;code&gt;INFOPLIST_FILE&lt;/code&gt;: generated keys and build-setting expansion can change the packaged values. Keep the expected identity independent of the settings under test.&lt;/p&gt;&#10;&lt;p&gt;The declared paths also describe the script&amp;rsquo;s file access for user-script sandboxing. Keep sandboxing enabled where possible and test these declarations with your Xcode version. The original target had it disabled. The stamp lives outside the signed app and is created only after validation. Removing it first, failing on missing values, and running unconditionally prevent an old stamp from becoming a false success after a failed check.&lt;/p&gt;&#10;&#10;&lt;h2 id="prove-that-wrong-builds-fail"&gt;Prove that wrong builds fail &lt;/h2&gt;&#10;&lt;p&gt;The historical implementation passed normal builds across all four original configurations. Four deliberate mutations failed: the production ID, production name, beta ID, and beta name, changed separately. Each failure reported the mismatched identity; the project file was restored afterward. These were build checks, not evidence of a fresh Archive after the fix.&lt;/p&gt;&#10;&lt;p&gt;For your project, repeat those negative controls, then restore the settings and archive twice without cleaning. Confirm the guard runs both times and inspect the resulting archive&amp;rsquo;s app plist. Also test a missing key and an unrecognized configuration.&lt;/p&gt;&#10;&lt;p&gt;This checks the app&amp;rsquo;s base plist only. Localized &lt;code&gt;InfoPlist.strings&lt;/code&gt; can affect the displayed name, and extensions need separate policies. Check the Archive action&amp;rsquo;s selected configuration in your scheme, then deliberately break its display name and confirm Archive fails before trusting the guard.&lt;/p&gt;&#10;</description></item><item><title>A latest-row query cannot tell you whether every device is current</title><link>https://eghuro.cz/posts/latest-row-query-is-not-a-source-telemetry-limit/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/latest-row-query-is-not-a-source-telemetry-limit/</guid><description>&lt;p&gt;A dashboard can label an account &amp;ldquo;current&amp;rdquo; while that account still has an outdated device. Selecting the latest event per account and application retains one observation; it discards observations from other devices. Describe that selection in the report before claiming that the source telemetry cannot distinguish devices.&lt;/p&gt;&#10;&lt;p&gt;The distinction matters during a security review: &amp;ldquo;latest observed state&amp;rdquo; and &amp;ldquo;any observed device needs attention&amp;rdquo; answer different questions. Correct arithmetic cannot repair a mislabeled metric.&lt;/p&gt;&#10;&#10;&lt;h2 id="reproduce-the-lost-device-with-synthetic-data"&gt;Reproduce the lost device with synthetic data &lt;/h2&gt;&#10;&lt;p&gt;Run this example with Python 3 and SQLite supporting window functions. The names, dates, and status values are synthetic; &lt;code&gt;current&lt;/code&gt; is a supplied classification, not an implementation of version comparison.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;sqlite3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;:memory:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executescript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;CREATE TABLE observations (&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; event_id INTEGER PRIMARY KEY,&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; account TEXT, app TEXT, device TEXT,&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; observed_at TEXT, current INTEGER&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;INSERT INTO observations VALUES&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; (1, &amp;#39;account-a&amp;#39;, &amp;#39;demo&amp;#39;, &amp;#39;laptop&amp;#39;, &amp;#39;2026-01-01T09:00:00Z&amp;#39;, 0),&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; (2, &amp;#39;account-a&amp;#39;, &amp;#39;demo&amp;#39;, &amp;#39;phone&amp;#39;, &amp;#39;2026-01-01T10:00:00Z&amp;#39;, 1),&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; (3, &amp;#39;account-b&amp;#39;, &amp;#39;demo&amp;#39;, &amp;#39;tablet&amp;#39;, &amp;#39;2026-01-01T10:00:00Z&amp;#39;, 0),&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; (4, &amp;#39;account-b&amp;#39;, &amp;#39;demo&amp;#39;, &amp;#39;tablet&amp;#39;, &amp;#39;2026-01-02T10:00:00Z&amp;#39;, 1);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;latest_account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;WITH ranked AS (&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; SELECT *, ROW_NUMBER() OVER (&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; PARTITION BY account, app&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; ORDER BY observed_at DESC, event_id DESC&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; ) AS rn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; FROM observations&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;SELECT account, current FROM ranked WHERE rn = 1 ORDER BY account&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;latest_devices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;WITH ranked AS (&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; SELECT *, ROW_NUMBER() OVER (&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; PARTITION BY account, app, device&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; ORDER BY observed_at DESC, event_id DESC&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; ) AS rn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; FROM observations&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;SELECT account, MAX(CASE WHEN current = 0 THEN 1 ELSE 0 END)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;FROM ranked WHERE rn = 1&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;GROUP BY account, app ORDER BY account&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;account_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latest_account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;exposure_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latest_devices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;account_rows&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;account-a&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;account-b&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;exposure_rows&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;account-a&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;account-b&amp;#39;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;latest account state:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;any latest-observed device outdated:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exposure_rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Both accounts have a current latest observation. Only &lt;code&gt;account-a&lt;/code&gt; has an outdated device among its latest per-device observations. The laptop disappeared from the first query because the phone reported later.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;account-b&lt;/code&gt; rows are a negative control. Aggregating every historical outdated event would flag the tablet even after its newer current observation. Rank within each device first, then aggregate the surviving states.&lt;/p&gt;&#10;&#10;&lt;h2 id="state-the-measurement-contract"&gt;State the measurement contract &lt;/h2&gt;&#10;&lt;p&gt;An accurate table label might be:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Accounts classified by their latest observed event per application, with ties resolved by event ID.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;That supports a claim about selected observations. It does not establish that every device on those accounts is current. Nor does the query establish what the ingestion system could identify before aggregation.&lt;/p&gt;&#10;&lt;p&gt;For an exposure report, define the device identity and observation window explicitly. A stable device key is a prerequisite for the second query. An installation identifier that rotates after reinstalling may represent installations rather than physical devices; a shared or missing identifier can merge unrelated observations. Inspect the schema and collection semantics before choosing the partition keys.&lt;/p&gt;&#10;&lt;p&gt;The example orders normalized UTC timestamps and breaks ties with a unique event ID. That makes the selection deterministic. It does not prove that a larger ID represents a later real-world event when timestamps tie. &lt;a&#10; href="https://www.sqlite.org/windowfunctions.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;SQLite&amp;rsquo;s window-function documentation&lt;/a&gt;&#10; describes how &lt;code&gt;ROW_NUMBER()&lt;/code&gt; follows the window&amp;rsquo;s ordering.&lt;/p&gt;&#10;&#10;&lt;h2 id="keep-unknown-devices-out-of-the-healthy-bucket"&gt;Keep unknown devices out of the healthy bucket &lt;/h2&gt;&#10;&lt;p&gt;Even latest-per-device aggregation describes observed devices. Retired devices, missing telemetry, and a stale last observation need an explicit policy. Define freshness before calling the result a current fleet inventory, and report unknown states separately instead of treating silence as evidence of health.&lt;/p&gt;&#10;&lt;p&gt;A reporting correction may leave every displayed count unchanged: the query can stay the same while its title and interpretation become accurate. Calculating a replacement device-level metric is separate work and requires source data that supports it.&lt;/p&gt;&#10;&lt;p&gt;Audit the partition keys behind one account-level health table, then check whether its title describes exactly the rows that survive.&lt;/p&gt;&#10;</description></item><item><title>Swift Testing: .serialized did not stop another suite deleting test files</title><link>https://eghuro.cz/posts/swift-testing-serialized-shared-cleanup/</link><pubDate>Wed, 28 Oct 2026 14:36:00 +0100</pubDate><guid>https://eghuro.cz/posts/swift-testing-serialized-shared-cleanup/</guid><description>&lt;p&gt;A filesystem test failed while writing its fixture, even though its suite used &lt;code&gt;@Suite(.serialized)&lt;/code&gt; and each fixture had a UUID directory. The underlying error was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;NSPOSIXErrorDomain Code=2 &amp;#34;No such file or directory&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Another suite was constructing the application core, whose initializer swept the shared cache directory. Injecting that startup operation, with an inert test value, stopped unrelated tests from deleting each other&amp;rsquo;s fixtures.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-unique-child-directory-still-has-a-shared-parent"&gt;A unique child directory still has a shared parent &lt;/h2&gt;&#10;&lt;p&gt;The failing suite created a directory, then wrote a file into it. Its own cleanup tests ran serially. Elsewhere in the same Swift Testing process, ordinary tests constructed the core as part of their setup. Every construction triggered a sweep of the common cache parent.&lt;/p&gt;&#10;&lt;p&gt;The destructive interleaving was small:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cleanup test: create cache/&amp;lt;UUID&amp;gt;/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;unrelated test: construct core -&amp;gt; sweep cache children&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cleanup test: write cache/&amp;lt;UUID&amp;gt;/fixture.bin -&amp;gt; missing directory&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;UUIDs prevented name collisions. They could not protect a child from an operation that deleted every child.&lt;/p&gt;&#10;&lt;p&gt;Swift Testing&amp;rsquo;s &lt;a&#10; href="https://github.com/swiftlang/swift-testing/blob/main/Sources/Testing/Traits/ParallelizationTrait.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;parallelization trait documentation&lt;/a&gt;&#10; describes the scope: &lt;code&gt;.serialized&lt;/code&gt; orders a suite&amp;rsquo;s contained tests and sub-suites. It does not order those tests relative to unrelated suites. Putting the attribute on the failing suite left the other constructors free to run alongside it.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-ordinary-test-construction-inert"&gt;Make ordinary test construction inert &lt;/h2&gt;&#10;&lt;p&gt;The fix moved the startup sweep behind an injected operation. Production kept the real sweep; the test dependency supplied a no-op. Tests that needed to verify sweeping explicitly selected the real operation.&lt;/p&gt;&#10;&lt;p&gt;Here is a generalized closure-based adaptation, independent of a dependency-injection library:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Foundation&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;StartupSweep&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Void&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;inert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StartupSweep&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;StartupSweep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&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="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StartupSweep&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;StartupSweep&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;manager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FileManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contentsOfDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;includingPropertiesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;CacheCore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startupSweep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StartupSweep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;startupSweep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The production composition root passes &lt;code&gt;.live(root: cacheRoot)&lt;/code&gt;. The ordinary test factory passes &lt;code&gt;.inert&lt;/code&gt;; neither caller relies on an initializer default. This example propagates filesystem errors so a cleanup test cannot pass by silently ignoring a failed sweep.&lt;/p&gt;&#10;&lt;p&gt;For a test-owned root containing a prepared &lt;code&gt;fixture&lt;/code&gt; file, the two assertions look like this. This is a test-body excerpt using &lt;code&gt;Testing&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Ordinary construction must preserve the existing fixture.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;CacheCore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startupSweep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inert&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FileManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fileExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;atPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Cleanup coverage explicitly opts into the real operation.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;CacheCore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startupSweep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;FileManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fileExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;atPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Use a disposable root for this check. Never point the illustrative live operation at a directory containing files you need. Injecting a separate root per test is another way to isolate filesystem work when the design permits it.&lt;/p&gt;&#10;&#10;&lt;h2 id="prove-the-constructor-contract-then-repeat-the-broader-target"&gt;Prove the constructor contract, then repeat the broader target &lt;/h2&gt;&#10;&lt;p&gt;The historical investigation used an isolated-suite control: the cleanup suite repeated 30 times with zero issues. The broader core target failed on its first run. That narrowed the search to interference from outside the serialized suite.&lt;/p&gt;&#10;&lt;p&gt;A regression test then created a fixture, built the core-under-test dependencies, and asserted that the file still existed. Before the injection fix, that assertion failed. It converted an intermittent write failure into a direct check of the destructive constructor behavior.&lt;/p&gt;&#10;&lt;p&gt;After the fix, the archived broader-target output reported:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;exit: 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;** TEST SUCCEEDED **&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;issues:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;max repetition:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;repetition 30&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test run with 165 tests in 28 suites passed after 2.806 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Those results verify the original fix under Xcode and iOS Simulator. The closure-based adaptation above has not been compiled separately. The reported test count is the runner&amp;rsquo;s summary, not a total multiplied by the repetition limit.&lt;/p&gt;&#10;&lt;p&gt;When a serialized suite loses files, check every initializer that can sweep their shared parent, including constructors called by unrelated suites.&lt;/p&gt;&#10;</description></item><item><title>A CVE cross-reference is not an advisory entry</title><link>https://eghuro.cz/posts/cve-cross-reference-is-not-an-advisory-entry/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/cve-cross-reference-is-not-an-advisory-entry/</guid><description>&lt;p&gt;A vulnerability index can have the correct set of CVE identifiers and still assign them to the wrong advisories. A regex that searches every paragraph also captures narrative references such as &amp;ldquo;related to CVE-…&amp;rdquo;. Extract identifiers from declaration blocks, and verify the resulting relationships before using the index to make patch or exposure claims.&lt;/p&gt;&#10;&lt;p&gt;The trap is easy to miss during deduplication: the referenced identifier may already have a legitimate entry elsewhere. Removing its false assignment changes the relationships without changing the global distinct-ID count.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-the-wrong-relationship-gets-created"&gt;How the wrong relationship gets created &lt;/h2&gt;&#10;&lt;p&gt;Consider two synthetic advisories. Every identifier below is fictional:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Advisory A, component Reader, declares &lt;code&gt;CVE-2099-10001&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Its impact paragraph mentions &lt;code&gt;CVE-2099-10002&lt;/code&gt; as a related issue.&lt;/li&gt;&#10;&lt;li&gt;Advisory B, component Writer, declares &lt;code&gt;CVE-2099-10002&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;An extractor that carries the current advisory and component into every regex match produces three rows. The false row associates Advisory A and Reader with the second identifier. Both the broken and corrected indexes contain two distinct identifiers.&lt;/p&gt;&#10;&lt;p&gt;This error can contaminate a patch matrix: an analyst might attach the wrong affected component or release boundary to a real vulnerability. It requires no malicious input. A crafted narrative mention could produce the same misassociation in a pipeline that ingests untrusted documents, but that is a possible abuse of the parser, not evidence of an exploited advisory feed.&lt;/p&gt;&#10;&#10;&lt;h2 id="filter-by-document-role-before-matching-identifiers"&gt;Filter by document role before matching identifiers &lt;/h2&gt;&#10;&lt;p&gt;For one fixed document layout, a practical repair is to collect complete paragraph text and process only blocks beginning with the declaration marker &lt;code&gt;CVE-&lt;/code&gt;. Collecting the whole paragraph matters: identifiers can be split across inline elements, and indentation should not change classification.&lt;/p&gt;&#10;&lt;p&gt;Here is a runnable Python example using a deliberately small HTML schema. Save it as &lt;code&gt;check_advisories.py&lt;/code&gt; and run &lt;code&gt;python3 check_advisories.py&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;re&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;html.parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTMLParser&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;\bCVE-\d&lt;/span&gt;&lt;span class="si"&gt;{4}&lt;/span&gt;&lt;span class="s2"&gt;-\d{4,}\b&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;FIXTURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;lt;article data-advisory=&amp;#34;A&amp;#34; data-component=&amp;#34;Reader&amp;#34;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;lt;p&amp;gt; &amp;lt;strong&amp;gt;CVE-2099-10001&amp;lt;/strong&amp;gt; &amp;lt;/p&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;lt;p&amp;gt;Related to &amp;lt;em&amp;gt;CVE-2099-10002&amp;lt;/em&amp;gt;.&amp;lt;/p&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;lt;/article&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;lt;article data-advisory=&amp;#34;B&amp;#34; data-component=&amp;#34;Writer&amp;#34;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; &amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt; CVE-2099-&amp;lt;span&amp;gt;10002&amp;lt;/span&amp;gt; &amp;lt;/p&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;lt;/article&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HTMLParser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;declarations_only&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;declarations_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;declarations_only&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_starttag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;article&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;data-advisory&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;data-component&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;p&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_endtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;p&amp;#34;&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;declarations_only&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;CVE-&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;article&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;declarations_only&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;declarations_only&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FIXTURE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;A&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Reader&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;CVE-2099-10001&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;B&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Writer&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;CVE-2099-10002&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;A&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Reader&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;CVE-2099-10002&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;broken&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="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;relationships: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; distinct IDs unchanged&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Expected output:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;relationships: 3 -&amp;gt; 2; distinct IDs unchanged&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="a-prefix-guard-has-a-document-contract"&gt;A prefix guard has a document contract &lt;/h2&gt;&#10;&lt;p&gt;This parser is a fixture, not a general advisory importer. A narrative paragraph could itself begin with a CVE identifier; another publisher might declare identifiers in a table or list. For production ingestion, select the publisher&amp;rsquo;s actual declaration element or structured field, record the source location, and reject or flag layouts the adapter does not recognize. The standard library&amp;rsquo;s &lt;a&#10; href="https://docs.python.org/3/library/html.parser.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;HTMLParser documentation&lt;/a&gt;&#10; describes its event interface; it cannot infer a publisher&amp;rsquo;s semantic roles for you.&lt;/p&gt;&#10;&lt;p&gt;Keep regression fixtures for narrative references, whitespace, and inline markup. Review added and removed &lt;code&gt;(advisory, component, CVE)&lt;/code&gt; tuples during a parser change, including declarations the new selector might accidentally discard.&lt;/p&gt;&#10;&lt;p&gt;Before trusting your next vulnerability-index refresh, check one known cross-reference against its owning advisory. A stable distinct-ID total cannot validate that assignment.&lt;/p&gt;&#10;</description></item><item><title>An offline cache TTL needs a clock-rollback policy</title><link>https://eghuro.cz/posts/offline-cache-clock-rollback/</link><pubDate>Tue, 27 Oct 2026 15:44:00 +0100</pubDate><guid>https://eghuro.cz/posts/offline-cache-clock-rollback/</guid><description>&lt;p&gt;AAn offline cache can pass its age check after the device clock moves backward. A timestamp later than &amp;ldquo;now&amp;rdquo; produces a negative age, and a check that only asks whether age is below the TTL accepts it. Reject future timestamps and remember the latest wall-clock time observed, while recognizing that neither check provides trusted elapsed time.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-negative-age-passed-the-upper-bound-check"&gt;A negative age passed the upper-bound check &lt;/h2&gt;&#10;&lt;p&gt;The feature reused a biometric-protected Keychain cache shared with a companion extension. Its initial eligibility rule was effectively:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;now - lastWritten &amp;lt;= maxAge&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That has two problems. Moving the clock before the write makes the age negative. Moving it backward after a previous access can also make an expired cache look young again without crossing the write timestamp.&lt;/p&gt;&#10;&lt;p&gt;The cache contained independently written items. Using the newest item&amp;rsquo;s date would make an older item appear refreshed, so eligibility used the oldest modification date. A separate check rejected any item dated in the future. Checking only the oldest date would miss a newer, future-dated item.&lt;/p&gt;&#10;&#10;&lt;h2 id="retain-the-latest-observed-time"&gt;Retain the latest observed time &lt;/h2&gt;&#10;&lt;p&gt;The repair added a clock mark in a separate Keychain item. Each eligibility read retained &lt;code&gt;max(previousMark, now)&lt;/code&gt;. If &lt;code&gt;now&lt;/code&gt; preceded the previous mark, access was refused. Writing the rolled-back time into the mark would let the next read pass, so repeated reads were part of the regression test.&lt;/p&gt;&#10;&lt;p&gt;This Python model isolates the arithmetic from Keychain storage. Inputs are timestamps in the same unit and a nonnegative maximum age. &lt;code&gt;None&lt;/code&gt; represents initial mark creation; it does not distinguish first use from a failed storage read.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cache_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;unavailable&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fresh&amp;#34;&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Persist the returned mark reliably. Define separate behavior for a missing mark, failed reads, and failed writes. Serialize the read/advance/write operation across its writers: two callers storing independently calculated maxima can otherwise overwrite a newer mark with an older one. This function does not implement those storage guarantees.&lt;/p&gt;&#10;&lt;p&gt;A successful online sign-in reset the mark to the current time, allowing recovery after a fast clock was legitimately corrected. That reset belonged to the authenticated online path, not to an offline retry.&lt;/p&gt;&#10;&#10;&lt;h2 id="refusing-access-did-not-mean-deleting-the-cache"&gt;Refusing access did not mean deleting the cache &lt;/h2&gt;&#10;&lt;p&gt;The main app&amp;rsquo;s expiry policy could not delete data the companion extension still needed. An expired result blocked that entry point and requested an online sign-in; it left the shared cache intact.&lt;/p&gt;&#10;&lt;p&gt;Offline use also had to stay separate from an ordinary signed-in state. Otherwise, the normal cache writer could rewrite cached data, advance its modification dates, and renew its apparent freshness without server contact. A timestamp is useful evidence of synchronization only while that write invariant holds.&lt;/p&gt;&#10;&lt;p&gt;Eligibility was checked again when opening the cache. A login screen left visible across the deadline could not reuse the earlier &amp;ldquo;fresh&amp;rdquo; result.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-the-rollback-twice"&gt;Test the rollback twice &lt;/h2&gt;&#10;&lt;p&gt;The original implementation&amp;rsquo;s red run reported four failed clock-mark assertions, including rollback refusal, repeated refusal, mark advancement, and online-sign-in reset. After the repair, the recorded core-target run passed 184 tests in 31 suites. Those are historical Xcode results, not a new Keychain integration run.&lt;/p&gt;&#10;&lt;p&gt;A separate run of the function above passed 15 synthetic-timestamp tests, covering expiry boundaries, future-dated items, repeated rollback refusal, and rechecking at open without mutating the cache. It did not exercise Keychain persistence. For example, two reads with these synthetic inputs must preserve the higher mark:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache_status&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;190&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&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="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;cache_status&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;190&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&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="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="a-high-water-mark-cannot-establish-time-that-was-never-observed"&gt;A high-water mark cannot establish time that was never observed &lt;/h2&gt;&#10;&lt;p&gt;A device can spend real time offline while its wall clock never exceeds the saved mark. These checks cannot prove how much time passed, guarantee remote revocation, or withstand a compromised device that can alter the protected state. Biometric access control does not authenticate the clock.&lt;/p&gt;&#10;&lt;p&gt;Test your cache by expiring it, moving the injected clock backward, and reading eligibility twice. Both reads should refuse access, and the second must not inherit a lowered mark.&lt;/p&gt;&#10;</description></item><item><title>iOS jailbreak protection in 2026: detection is a risk signal, not a security boundary</title><link>https://eghuro.cz/posts/ios-jailbreak-protection-2026/</link><pubDate>Mon, 26 Oct 2026 23:20:00 +0100</pubDate><guid>https://eghuro.cz/posts/ios-jailbreak-protection-2026/</guid><description>&lt;p&gt;An iOS app had jailbreak detection, passed an external pentest, and still spent more than a year accepting every jailbroken device because an asynchronous result was discarded. After that bug was fixed, the checks were still a 2025 list of Cydia-era paths that missed current rootless and hidden jailbreaks.&lt;/p&gt;&#10;&lt;p&gt;If the requirement says “the app must not run on a jailbroken phone,” the useful answer in 2026 is not a longer path list. Treat jailbreak detection as a bypassable risk signal, decide what sensitive operation the signal protects, enforce that decision at the server where possible, and test the whole policy against current jailbreak and bypass tools.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-short-version"&gt;The short version &lt;/h2&gt;&#10;&lt;p&gt;A defensible implementation today has five parts:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Write a threat model that explains why a compromised device materially changes this app&amp;rsquo;s risk.&lt;/li&gt;&#10;&lt;li&gt;Combine independent local signals and run them near sensitive operations, not only at launch.&lt;/li&gt;&#10;&lt;li&gt;Apply a proportional policy: observe, step up, restrict sensitive data, or refuse service according to confidence and impact.&lt;/li&gt;&#10;&lt;li&gt;Put the final authorization decision on a server and use App Attest to protect app-to-server requests. Do not call App Attest to prove that a device is not jailbroken.&lt;/li&gt;&#10;&lt;li&gt;Test the release binary statically, dynamically, and by trying to bypass it on representative clean and jailbroken devices.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is also the difference between “the code contains a jailbreak check” and an engineering conclusion that can survive review.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-a-working-check-silently-stopped-working"&gt;How a working check silently stopped working &lt;/h2&gt;&#10;&lt;p&gt;The first implementation was conventional:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;sysctl&lt;/code&gt; checked &lt;code&gt;P_TRACED&lt;/code&gt; and terminated a release build under a debugger;&lt;/li&gt;&#10;&lt;li&gt;known paths such as &lt;code&gt;/Applications/Cydia.app&lt;/code&gt;, &lt;code&gt;/bin/bash&lt;/code&gt;, &lt;code&gt;/usr/sbin/sshd&lt;/code&gt;, and &lt;code&gt;/etc/apt&lt;/code&gt; were checked;&lt;/li&gt;&#10;&lt;li&gt;the app tried to write &lt;code&gt;/private/jailbreak.txt&lt;/code&gt; outside its sandbox;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;cydia://&lt;/code&gt; was passed to &lt;code&gt;canOpenURL&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;loaded Mach-O images were searched for &lt;code&gt;MobileSubstrate&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It ran only in non-Debug builds on physical devices. That was sensible, and the pentest team verified it.&lt;/p&gt;&#10;&lt;p&gt;One probe had never worked in the affected app targets: &lt;code&gt;canOpenURL&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for schemes that are not declared under &lt;code&gt;LSApplicationQueriesSchemes&lt;/code&gt;, and &lt;code&gt;cydia&lt;/code&gt; was not declared. An app extension could not use &lt;code&gt;UIApplication.shared&lt;/code&gt; at all. Current IOSSecuritySuite also &lt;a&#10; href="https://github.com/securing/IOSSecuritySuite/blob/master/IOSSecuritySuite/JailbreakChecker.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;removed the &lt;code&gt;cydia://&lt;/code&gt; check&lt;/a&gt;&#10; because an App Store app registered the scheme and caused false positives. URL-scheme probes are weak evidence even when configured correctly.&lt;/p&gt;&#10;&lt;p&gt;Then the check moved behind a function that started a &lt;code&gt;Task&lt;/code&gt;, returned from the function immediately, and ignored the task&amp;rsquo;s &lt;code&gt;Bool&lt;/code&gt;. The user interface always received &lt;code&gt;true&lt;/code&gt;. The debugger check still appeared to work because &lt;code&gt;fatalError&lt;/code&gt; happened inside the task, masking the control-flow bug. Later refactors moved the broken call without changing its behavior.&lt;/p&gt;&#10;&lt;p&gt;The eventual fix was not another jailbreak signature. It was to make validation genuinely asynchronous, await it, and test the production call site as well as the checker.&lt;/p&gt;&#10;&lt;p&gt;That history produced three reusable lessons:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Test enforcement, not just detection. A perfect signal whose result is ignored protects nothing.&lt;/li&gt;&#10;&lt;li&gt;Test every app entry point and extension. The main app, credential provider, widget, and background path do not necessarily share a process or API surface.&lt;/li&gt;&#10;&lt;li&gt;Test the release configuration on hardware. A Debug simulator test proves code structure, not production behavior when the checks are compiled out there.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="when-owasp-says-to-implement-jailbreak-detection"&gt;When OWASP says to implement jailbreak detection &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/news/2026/07/04/mastg-v200-release"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG 2.0, released in July 2026&lt;/a&gt;&#10;, makes the trade-off unusually explicit. &lt;a&#10; href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-BEST-0030&lt;/a&gt;&#10; is currently tagged Android and says “root detection,” but its risk principle applies directly to the equivalent iOS control: it is a bypassable, cost-raising environment signal. It should be used only when compromised-device risk materially affects the app. The selection and response should follow the threat model and risk tolerance.&lt;/p&gt;&#10;&lt;p&gt;That points toward jailbreak protection when a compromised platform makes a concrete abuse case easier, for example:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;extracting high-value secrets or regulated data from the app process or local cache;&lt;/li&gt;&#10;&lt;li&gt;instrumenting transactions, credential filling, cryptographic operations, or fraud controls;&lt;/li&gt;&#10;&lt;li&gt;changing a game, licensing, payment, or content-access decision made locally;&lt;/li&gt;&#10;&lt;li&gt;automating a protected API through a modified client;&lt;/li&gt;&#10;&lt;li&gt;defeating controls that depend on the sandbox, Keychain access policy, or trusted user interaction.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It does not make jailbreak detection a universal mobile-app baseline. A low-risk public-content app may gain little while inheriting false positives, support work, accessibility problems, and an exclusion policy it cannot reliably enforce.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/controls/MASVS-RESILIENCE-1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS-RESILIENCE-1&lt;/a&gt;&#10; states the security concern: many other controls assume that the platform&amp;rsquo;s sandboxing, secure storage, and biometrics can be trusted. But the wider &lt;a&#10; href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS resilience guidance&lt;/a&gt;&#10; treats anti-tampering and runtime self-protection as defense in depth, never a replacement for the baseline security architecture.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-current-techniques-are-rootless-and-designed-to-hide"&gt;The current techniques are rootless and designed to hide &lt;/h2&gt;&#10;&lt;p&gt;The old signature list described rootful Cydia and MobileSubstrate installations. Current jailbreaks changed the filesystem layout and then added explicit concealment.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine&lt;/a&gt;&#10; is a rootless jailbreak. Its bootstrap commonly appears below &lt;code&gt;/var/jb&lt;/code&gt;, with ElleKit providing tweak injection. Dopamine 1.1 &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/1.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;introduced &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;&lt;/a&gt;&#10; in the environment of processes with tweaks enabled, while noting that &lt;code&gt;/var/jb&lt;/code&gt; might become optional to impede detection. Palera1n has its own loader and helper artifacts. Frida may appear as &lt;code&gt;frida-server&lt;/code&gt;, Frida Gadget, a loaded image, or merely runtime behavior after its obvious names have been changed.&lt;/p&gt;&#10;&lt;p&gt;At the time of this review, Dopamine&amp;rsquo;s current upstream release was &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/3.0.12"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;3.0.12, published October 20, 2026&lt;/a&gt;&#10;. A signature inventory that stops at Cydia or even Dopamine 2 is already historical.&lt;/p&gt;&#10;&lt;p&gt;Dopamine 2.4&amp;rsquo;s &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;release notes&lt;/a&gt;&#10; are the more important evidence: “Hide Jailbreak” was changed specifically to hide the jailbreak to the point where it should be undetectable. &lt;a&#10; href="https://github.com/roothide/Dopamine-roothide"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RootHide&amp;rsquo;s Dopamine integration&lt;/a&gt;&#10; randomizes the jailbreak root and selectively keeps jailbreak changes away from blacklisted apps. Its purpose is jailbreak-detection bypass.&lt;/p&gt;&#10;&lt;p&gt;A 2026 local detector should therefore look across different classes of evidence, for example:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;rootful and rootless files, directories, symlinks, package-manager state, and randomized &lt;code&gt;.jbroot-*&lt;/code&gt; roots;&lt;/li&gt;&#10;&lt;li&gt;whether the sandbox permits a collision-safe write to a protected path;&lt;/li&gt;&#10;&lt;li&gt;debugger state and failure of the debugger-status query itself;&lt;/li&gt;&#10;&lt;li&gt;suspicious environment keys such as &lt;code&gt;DYLD_INSERT_LIBRARIES&lt;/code&gt; and &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;loaded images associated with Frida, ElleKit, Substrate, Substitute, libhooker, RootHide, or Dopamine&amp;rsquo;s system hook;&lt;/li&gt;&#10;&lt;li&gt;behavior that should be prohibited by the sandbox, where the API is appropriate for App Store software;&lt;/li&gt;&#10;&lt;li&gt;server-side anomalies and app-integrity assertions.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Use more than one API for important filesystem evidence. A &lt;code&gt;FileManager.fileExists&lt;/code&gt; check, a POSIX &lt;code&gt;lstat&lt;/code&gt;, and directory enumeration do not create certainty, but an attacker now has more hooks to maintain consistently.&lt;/p&gt;&#10;&lt;p&gt;Do not equate more signatures with “state of the art.” Public signature lists are useful coverage for unsophisticated and accidentally exposed jailbreaks. RootHide, renamed Frida components, or a patch to the function that aggregates all checks can make every local answer say “clean.” OWASP&amp;rsquo;s &lt;a&#10; href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-RESILIENCE/MASTG-KNOW-0084"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;jailbreak detection knowledge article&lt;/a&gt;&#10; says the same thing: these defenses are not effective by themselves, although distributing them through the app increases the cost of bypassing them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-response-matters-more-than-the-boolean"&gt;The response matters more than the Boolean &lt;/h2&gt;&#10;&lt;p&gt;“Jailbroken: yes or no” is too weak a model for a fallible set of signals. Record an internal risk result with the evidence and confidence, then map it to a product policy.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Situation&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Reasonable response&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;One weak or ambiguous signal&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Telemetry, retry, or step-up authentication&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Several independent local signals&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Block sensitive operations; avoid disclosing the exact failed probe&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Debugger or injected runtime tool in a high-risk flow&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Refuse the operation and invalidate relevant server state&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Unsupported App Attest on an otherwise supported device&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Treat as a risk signal according to the documented policy&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Enterprise-managed fleet with a strict posture requirement&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Evaluate Managed Device Attestation at the relying service&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;OWASP recommends proportional responses and warns that aggressive lockout can affect legitimate users. For an app that stores documents and credentials, a useful soft block can allow login and folder metadata while refusing document bodies, downloads, offline queues, and credential release.&lt;/p&gt;&#10;&lt;p&gt;That restriction must be in application logic, not just a screen with a Close button. Better still, the server should not return the sensitive payload. Otherwise, a hook can dismiss the screen or call the same API directly.&lt;/p&gt;&#10;&lt;p&gt;A hard process exit is appropriate only if the threat model justifies denial of all functionality and the product accepts false positives. Crashing also gives poor observability and can turn an operational failure into a permanent lockout. A generic refusal with server-side telemetry is usually easier to operate.&lt;/p&gt;&#10;&#10;&lt;h2 id="app-attest-authenticates-app-instances-not-clean-devices"&gt;App Attest authenticates app instances, not clean devices &lt;/h2&gt;&#10;&lt;p&gt;Apple&amp;rsquo;s &lt;a&#10; href="https://developer.apple.com/videos/play/wwdc2026/201"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;WWDC26 App Attest session&lt;/a&gt;&#10; describes the current protocol:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;the server initiates enrollment and issues a challenge;&lt;/li&gt;&#10;&lt;li&gt;the app creates a Secure Enclave-bound key and attests it;&lt;/li&gt;&#10;&lt;li&gt;the server validates and stores the attestation and public key;&lt;/li&gt;&#10;&lt;li&gt;sensitive requests include a fresh server challenge in an assertion over the payload;&lt;/li&gt;&#10;&lt;li&gt;the server validates the signature and requires a strictly increasing counter to reject replays.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is valuable. It lets the server distinguish a valid published app instance, detect unexpected signing or launch categories, and bind authorization to a payload that has not been altered in transit.&lt;/p&gt;&#10;&lt;p&gt;It is not a jailbreak oracle. The same 2026 session says that a compromised device can still pass attestations and broker valid attestations for modified clients. Apple provides the fraud metric to help detect that pattern. Use App Attest as another server-verified signal, with retry and onboarding policy for legitimate failures.&lt;/p&gt;&#10;&lt;p&gt;For managed organizational devices, &lt;a&#10; href="https://support.apple.com/guide/security/managed-device-attestation-sec8a37b4cb2/web"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Managed Device Attestation&lt;/a&gt;&#10; provides Secure Enclave-backed evidence about device identity and security posture. That is stronger input for a managed-fleet trust decision, but it is not a general capability that a consumer app can require from every unmanaged phone.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-implementation-behavior-and-bypass-resistance"&gt;Test implementation, behavior, and bypass resistance &lt;/h2&gt;&#10;&lt;p&gt;OWASP MASTG 2 separates two questions that teams often collapse into one.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0240"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-TEST-0240&lt;/a&gt;&#10; is static: extract the shipped app package and inspect the binaries for jailbreak-detection mechanisms. This catches missing code, build flags that compiled it away, or a check that never reached a target. OWASP warns that automated static analysis is not exhaustive; manual reverse engineering may be necessary.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0241"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-TEST-0241&lt;/a&gt;&#10; is dynamic: run the app, identify the checks, and try to bypass them. A test fails when detection is absent, but passing a single predefined bypass tool doesn&amp;rsquo;t prove resilience because its scripts may be incomplete or outdated.&lt;/p&gt;&#10;&lt;p&gt;For a real release, I would keep this matrix with the evidence from each run:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Test&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expected evidence&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clean supported iPhones and iOS versions&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No false refusal; all sensitive flows work&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Release build on physical hardware&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Checks execute; Debug/simulator exclusions do not leak into the archive&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Rootful and current rootless jailbreaks&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each applicable signal fires; policy is enforced&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Dopamine Hide Jailbreak and RootHide blacklist&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Document what remains visible and what becomes undetectable&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Frida server and embedded/renamed Gadget&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Known artifacts are detected; renamed forms challenge bypass resistance; outcomes are recorded&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Hook the aggregate result and individual APIs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sensitive server operation still requires valid authorization and assertions&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Main app, extensions, background entry points&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each entry point validates before releasing its protected capability&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Async and error paths&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Results are awaited; query failures follow the documented fail-open or fail-closed policy&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Pentest bypass configuration&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;It cannot enter a production archive; CI tests that invariant&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Unit-test injected signals, cleanup failures, query failures, and clean-device results. Then add integration tests around the call site: the protected operation must not run after validation fails. Finally, let a tester attack the binary rather than merely demonstrating a known file on a jailbroken test phone.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-reviewable-2026-lege-artis-baseline"&gt;A reviewable 2026 “lege artis” baseline &lt;/h2&gt;&#10;&lt;p&gt;“State of the art” is not established by citing one library or by counting checks. For an engineering review, the conclusion should be reproducible, dated, scoped, and explicit about uncertainty. This is the practical baseline I would use when assessing whether an iOS app&amp;rsquo;s jailbreak protection was implemented with professional care in October 2026:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Define the question and date.&lt;/strong&gt; State which app version, release configuration, iOS range, device classes, sensitive operations, and attacker capabilities are in scope. Practice changes as jailbreaks and iOS change.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Identify the risk before prescribing the control.&lt;/strong&gt; Connect compromised-platform capabilities to actual assets and abuse cases. Explain why jailbreak detection materially affects this app.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Select authoritative and current sources.&lt;/strong&gt; Record versions and access dates for OWASP MASVS/MASTG, Apple platform documentation, relevant jailbreak release notes, and the implementation under review. Treat public detection libraries as examples, not normative standards.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Preserve and identify the evidence.&lt;/strong&gt; Record the source commit, archive hash, build settings, entitlements, tool versions, device/iOS versions, jailbreak version, and test logs. Work from a reproducible copy.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Separate facts from interpretation.&lt;/strong&gt; “The production binary contains &lt;code&gt;/var/jb&lt;/code&gt; and &lt;code&gt;JB_ROOT_PATH&lt;/code&gt; checks” is a fact. “Those checks make bypass harder but cannot establish a clean device” is the interpretation supported by OWASP and current bypass projects.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Analyze the whole control path.&lt;/strong&gt; Review signal collection, aggregation, async propagation, every entry point, the product response, server authorization, telemetry, and release-lane exceptions. Include negative findings and dead code.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Test both presence and effectiveness.&lt;/strong&gt; Perform static inspection, clean-device compatibility tests, dynamic execution on relevant jailbreaks, and bypass attempts. A unit test or a pentest on one jailbreak version is not enough.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Consider alternatives and false positives.&lt;/strong&gt; Explain why a detected artifact could be benign, why a missing artifact does not establish cleanliness, and why the chosen response is proportional.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;State limitations.&lt;/strong&gt; Local checks can be hooked or patched; hidden jailbreaks may expose no artifact; App Attest can succeed on a compromised device; the tested device set is finite.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make a bounded conclusion.&lt;/strong&gt; Say whether the examined implementation follows the documented risk policy and raises attacker cost for the tested scenarios. Do not conclude that it guarantees a non-jailbroken device.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This structure mirrors a sound software expert analysis: sources and methods are identified, observations remain distinguishable from interpretation, contrary evidence is not hidden, limitations reduce the stated certainty, and the conclusion answers only the technical question examined. It is guidance for an engineering assessment, not a legal conclusion about liability, contractual compliance, or negligence.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;The old implementation failed twice: first because application control flow discarded a valid result, then because its threat model had aged past its Cydia-era signatures. The durable fix is a process—current layered signals, proportional server-enforced policy, and repeated adversarial testing—not a permanent &lt;code&gt;isJailbroken&lt;/code&gt; function.&lt;/p&gt;&#10;&lt;p&gt;Take one sensitive operation in your app and trace it today from device signal to server authorization. If any step can ignore, hide, or locally override the result, that is the next protection to fix.&lt;/p&gt;&#10;&#10;&lt;h2 id="references"&gt;References &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS: Resilience against reverse engineering and tampering&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/news/2026/07/04/mastg-v200-release"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG 2.0.0 release announcement&lt;/a&gt;&#10;, July 4, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/controls/MASVS-RESILIENCE-1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS-RESILIENCE-1: Platform integrity&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-BEST-0030: Implementing Root Detection&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-RESILIENCE/MASTG-KNOW-0084"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-KNOW-0084: Jailbreak Detection&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0240"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-TEST-0240: Jailbreak Detection in Code&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0241"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-TEST-0241: Runtime Use of Jailbreak Detection Techniques&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://developer.apple.com/videos/play/wwdc2026/201"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple: Secure your apps with App Attest, WWDC26&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://support.apple.com/guide/security/managed-device-attestation-sec8a37b4cb2/web"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple Platform Security: Managed Device Attestation&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 2.4 release notes: Hide Jailbreak&lt;/a&gt;&#10;, February 16, 2025.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/1.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 1.1 release notes: &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;&lt;/a&gt;&#10;, June 16, 2023.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine releases&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/3.0.12"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 3.0.12 release&lt;/a&gt;&#10;, October 20, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/roothide/Dopamine-roothide"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RootHide integration for Dopamine 3&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/securing/IOSSecuritySuite/blob/master/IOSSecuritySuite/JailbreakChecker.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;IOSSecuritySuite jailbreak checker&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Adapt security skills to your project's risks: from Go to OWASP LLM and iOS</title><link>https://eghuro.cz/posts/adapt-security-skills-to-project-risks/</link><pubDate>Sun, 25 Oct 2026 21:50:00 +0100</pubDate><guid>https://eghuro.cz/posts/adapt-security-skills-to-project-risks/</guid><description>&lt;p&gt;A security skill written for Go covers different questions than one written for an LLM application or an iOS app. I started with &lt;code&gt;golang-security&lt;/code&gt; from &lt;a&#10; href="https://github.com/samber/cc-skills-golang"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;samber/cc-skills-golang&lt;/a&gt;&#10;, asked Claude to adapt it using the OWASP Top 10 for LLM Applications, and later carried that approach into production iOS development.&lt;/p&gt;&#10;&lt;p&gt;The most useful part of the adaptation was aligning the review questions with the project&amp;rsquo;s risks and the company&amp;rsquo;s values.&lt;/p&gt;&#10;&#10;&lt;h2 id="start-with-a-review-method"&gt;Start with a review method &lt;/h2&gt;&#10;&lt;p&gt;I was working in Go, a language I was unfamiliar with. I used my security skills alongside other Go skills to keep the implementation in check.&lt;/p&gt;&#10;&lt;p&gt;The security skill provided a repeatable method: separate modes for coding, reviewing changes, and auditing a codebase; questions about trust boundaries and attacker-controlled input; and instructions to trace data through surrounding code before reporting a finding. Its references and checklist gave the agent somewhere to look beyond the main instructions.&lt;/p&gt;&#10;&#10;&lt;h2 id="adapt-owasp-categories-to-the-application"&gt;Adapt OWASP categories to the application &lt;/h2&gt;&#10;&lt;p&gt;I asked Claude to create a new skill using the &lt;a&#10; href="https://genai.owasp.org/llm-top-10"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP Top 10 for LLM Applications (2025)&lt;/a&gt;&#10;, adapted to the project&amp;rsquo;s LLM and Model Context Protocol (MCP) surfaces.&lt;/p&gt;&#10;&lt;p&gt;The resulting &lt;code&gt;llm-security&lt;/code&gt; skill kept the review modes and evidence requirements. It mapped the categories to tools, resources, prompts, and content returned to the model. The Go checklist stayed active for the code underneath.&lt;/p&gt;&#10;&lt;p&gt;For &lt;a&#10; href="https://genai.owasp.org/llmrisk/llm01-prompt-injection"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;prompt injection&lt;/a&gt;&#10;, the reviewer needs to identify whose text reaches the model and what an attacker can control. A returned document body or filename can carry instructions. For &lt;a&#10; href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;excessive agency&lt;/a&gt;&#10;, the questions concern tool permissions, reachable side effects, and authorization enforced outside the model.&lt;/p&gt;&#10;&lt;p&gt;Those questions give the agent a path through the repository. It can follow a tool result back to its data source, then follow a tool call forward to the operation it performs. The skill also asks it to account for existing protections when assessing impact.&lt;/p&gt;&#10;&#10;&lt;h2 id="repeat-the-adaptation-for-ios"&gt;Repeat the adaptation for iOS &lt;/h2&gt;&#10;&lt;p&gt;Later, I applied the same approach to an iOS app using the &lt;a&#10; href="https://owasp.github.io/www-project-mobile-top-10"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP Mobile Top 10 (2024)&lt;/a&gt;&#10;. That mobile security skill became part of production development.&lt;/p&gt;&#10;&lt;p&gt;The review method carried over. The mapping changed to platform APIs and the code responsible for them:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Mobile risk&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Concrete review question&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Improper credential usage&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Where are tokens stored, and which Keychain access settings apply?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Insufficient input/output validation&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;What can a deep link or share-extension input cause the app to do?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Insecure data storage&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Which protection classes apply to cached and temporary user files?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Inadequate privacy controls&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Where can sensitive data appear outside its intended screen, including logs and notifications?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Repository context matters here. If a shared SDK owns authentication or networking, the skill needs to direct the reviewer into that SDK. Stopping at the changed Swift view would leave the relevant behavior unexamined.&lt;/p&gt;&#10;&lt;p&gt;For a mobile adaptation, &lt;a&#10; href="https://mas.owasp.org/MASVS"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS&lt;/a&gt;&#10; supplies verification controls, and &lt;a&#10; href="https://mas.owasp.org/MASTG"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG&lt;/a&gt;&#10; provides testing guidance for checking them.&lt;/p&gt;&#10;&#10;&lt;h2 id="turn-company-values-into-review-rules"&gt;Turn company values into review rules &lt;/h2&gt;&#10;&lt;p&gt;Company values need concrete instructions too. For example, a company that prioritizes privacy can require reviewers to trace user data into logging, analytics, and crash reporting. That turns a broad preference into something an agent can inspect.&lt;/p&gt;&#10;&lt;p&gt;The mobile skill also records settled product decisions. An intentional tradeoff needs a rationale and the protections that still apply so that each review can assess the implementation against that decision. New evidence or changed assumptions can justify reopening it.&lt;/p&gt;&#10;&lt;p&gt;Severity needs the same local reasoning. OWASP provides risk categories; the product&amp;rsquo;s data, attacker access, and existing controls determine a finding&amp;rsquo;s impact.&lt;/p&gt;&#10;&lt;p&gt;A fresh adaptation request could be:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Use the existing security skill as a template. Keep its review workflow&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;and evidence requirements. Adapt the relevant OWASP categories to this&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;repository&amp;#39;s entry points, data flows, and platform APIs. Link each check&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;to the responsible code and a primary reference. Record product decisions&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;with their rationale. Preserve the language-level checks alongside it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Tailoring a security skill gives the agent concrete questions about the product it is building. Take one OWASP category in your current skill and map it to the code, data, and product decision the reviewer should examine.&lt;/p&gt;&#10;</description></item><item><title>A repeatable agentic development workflow: context, tickets, implementation, review</title><link>https://eghuro.cz/posts/agentic-development-workflow/</link><pubDate>Sun, 25 Oct 2026 18:25:00 +0100</pubDate><guid>https://eghuro.cz/posts/agentic-development-workflow/</guid><description>&lt;p&gt;Giving a coding agent a feature request and accepting the first working diff is easy. Repeating that across projects is where things get messy: the agent has to rediscover the domain, invent missing requirements, decide what “done” means, and review its own assumptions.&lt;/p&gt;&#10;&lt;p&gt;Here is the setup I now reuse across stacks. It has supported ten iOS app releases this year, helped clean up an existing app built with loose agent guidance, and helped a Go implementation recover after its first approach fell apart - to name a few examples. The Go work also involved a language stack and MCP technology I had not used before. The process didn&amp;rsquo;t provide that expertise on its own; it made the gaps visible early enough to address them.&lt;/p&gt;&#10;&#10;&lt;h2 id="give-the-agent-durable-context"&gt;Give the agent durable context &lt;/h2&gt;&#10;&lt;p&gt;Start with &lt;a&#10; href="https://github.com/mattpocock/skills"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;mattpocock/skills&lt;/a&gt;&#10; and run its repository setup. Keep a short domain vocabulary in &lt;code&gt;CONTEXT.md&lt;/code&gt; (or the current upstream convention, &lt;code&gt;GLOSSARY.md&lt;/code&gt;). Record consequential design choices as ADRs. The agent can then use the same terms in discussions, tickets, code, and reviews.&lt;/p&gt;&#10;&lt;p&gt;For a change whose shape is uncertain, use &lt;code&gt;/grill-with-docs&lt;/code&gt; to work through the decisions and write them down. Run &lt;code&gt;/to-spec&lt;/code&gt; once the conversation has enough detail. Break the spec into small, ordered tickets with &lt;code&gt;/to-issues&lt;/code&gt; in older installations or &lt;code&gt;/to-tickets&lt;/code&gt; in the current upstream skills. Then use &lt;code&gt;/implement&lt;/code&gt; against those tickets.&lt;/p&gt;&#10;&lt;p&gt;The sequence is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;shared vocabulary + ADRs → grill → spec → tickets → implement → review&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can enter the sequence at whichever step the work needs. A small, well-specified fix may already have a ticket. An uncertain feature should be grilled before an agent starts writing code.&lt;/p&gt;&#10;&#10;&lt;h2 id="put-tickets-where-the-project-works"&gt;Put tickets where the project works &lt;/h2&gt;&#10;&lt;p&gt;The tracker is an adapter for the process. One project keeps issues in a local &lt;code&gt;docs/TODO.txt&lt;/code&gt;; others use Vikunja or Taiga. What matters is that the agent knows where the authoritative spec and tickets live, how to record blockers, and how to update their status. Write those rules into the repository&amp;rsquo;s agent documentation. A tracker integration skill can handle the API details without changing the development sequence.&lt;/p&gt;&#10;&lt;p&gt;This also makes the setup portable. Moving to a new project means teaching the agent that project&amp;rsquo;s domain, tracker, and checks, rather than rebuilding the workflow.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-implementation-answer-to-the-spec"&gt;Make implementation answer to the spec &lt;/h2&gt;&#10;&lt;p&gt;Implementation includes tests and a diff review. Review the result against two different questions: does the code follow the project&amp;rsquo;s standards, and does it actually satisfy the originating spec? The latter catches plausible code that solves the wrong problem. &lt;a&#10; href="https://eghuro.cz/posts/standards-vs-spec-review"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;A previous review&lt;/a&gt;&#10; found duplicate-write paths after a large test suite passed because it checked a safety claim against the call path.&lt;/p&gt;&#10;&lt;p&gt;Where the project&amp;rsquo;s rules allow it, use a different model family for review. A typical split is Claude writing the code and GPT reviewing it, since a different family brings different blind spots. Claude reviewing Claude has also proven decent, so one family is still a workable setup when you can only use one provider. For larger changes, a &lt;code&gt;multi-review&lt;/code&gt; skill can run distinct axes such as correctness, quality, tests and performance, security, and, for frontend, accessibility. Give each reviewer the same bounded diff and the actual spec; collect actionable findings back into the tracker.&lt;/p&gt;&#10;&lt;p&gt;Attach stack-specific skills where they help. Go work can load guidance for concurrency, error handling, testing, and security. iOS work can use Swift and mobile guidance. For exposed web APIs, mobile clients, or MCP tools, apply relevant OWASP-based web, mobile, and LLM security skills during implementation and review. A checklist gives the agent concrete questions to ask; it does not turn a code review into a security certification.&lt;/p&gt;&#10;&lt;p&gt;Start with one change: write down the vocabulary and decision, turn the request into a spec and tickets, implement one ticket, and review the diff against the spec and its security boundaries. Adjust the tracker, skills, and review axes to fit the next project.&lt;/p&gt;&#10;</description></item><item><title>Moodle 5.3 Classic theme serves an empty stylesheet: 'Undefined variable $mds-spacing-md'</title><link>https://eghuro.cz/posts/moodle-53-classic-theme-empty-stylesheet/</link><pubDate>Sat, 24 Oct 2026 14:58:00 +0200</pubDate><guid>https://eghuro.cz/posts/moodle-53-classic-theme-empty-stylesheet/</guid><description>&lt;p&gt;After an upgrade to Moodle 5.3, every page on the Classic theme lost its layout. The theme&amp;rsquo;s stylesheet came back like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ curl -s -o /dev/null -w &amp;#39;%{http_code} %{size_download}\n&amp;#39; \&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#39;https://moodle.example.com/theme/styles_debug.php?theme=classic&amp;amp;type=scss&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;200 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s a 200 with an empty body, and nothing in the logs. With debugging forced to developer level, the SCSS compiler said:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error while compiling SCSS: Undefined variable $mds-spacing-md: /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle/course.scss on line 132, at column 17&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Classic imports Boost&amp;rsquo;s SCSS, and in 5.3 that SCSS uses the new design system&amp;rsquo;s tokens. Classic never imports them. One &lt;code&gt;@import&lt;/code&gt; fixes it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Moodle 5.3.0, PHP 8.4.27, in a Docker image of our own, upgraded from 5.0&lt;/li&gt;&#10;&lt;li&gt;The site&amp;rsquo;s theme is Classic. It left core in 5.3, so the image installs it from &lt;a&#10; href="https://github.com/moodlehq/moodle-theme_classic"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;moodlehq/moodle-theme_classic&lt;/a&gt;&#10; (at commit &lt;code&gt;363a179&lt;/code&gt;, the only one when I checked)&lt;/li&gt;&#10;&lt;li&gt;Theme designer mode was on, so pages loaded the theme through &lt;code&gt;styles_debug.php&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I rehearsed the upgrade on a copy of the production database. Every URL I tried returned 200, and &lt;code&gt;admin/cli/checks.php&lt;/code&gt; reported all checks OK. On production, the pages had no layout.&lt;/p&gt;&#10;&#10;&lt;h2 id="two-things-that-looked-like-the-cause"&gt;Two things that looked like the cause &lt;/h2&gt;&#10;&lt;p&gt;The browser console had two errors:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[Error] Failed to load resource: the server responded with a status of 404 (Not Found) (client.bundle.js.map, line 0)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[Error] Failed to load resource: the server responded with a status of 404 (Not Found) (react.bundle.mjs.map, line 0)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Those are source maps. The browser asks for them only for its developer tools, and Moodle doesn&amp;rsquo;t ship them. They don&amp;rsquo;t affect the layout.&lt;/p&gt;&#10;&lt;p&gt;The import map had a few 404s as well, for entries like &lt;code&gt;/core/esm/&amp;lt;rev&amp;gt;/bootstrap/&lt;/code&gt;. Those entries end in a slash because they map a whole prefix, so the bare URL has nothing to serve. The modules under them loaded.&lt;/p&gt;&#10;&lt;p&gt;The problem showed up when I fetched every stylesheet the front page links to and printed its size:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s https://moodle.example.com/ -o index.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -oE &lt;span class="s1"&gt;&amp;#39;&amp;lt;link[^&amp;gt;]+stylesheet[^&amp;gt;]*&amp;gt;&amp;#39;&lt;/span&gt; index.html &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; grep -oE &lt;span class="s1"&gt;&amp;#39;href=&amp;#34;[^&amp;#34;]+&amp;#34;&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sed &lt;span class="s1"&gt;&amp;#39;s/href=&amp;#34;//; s/&amp;#34;$//; s/&amp;amp;amp;/\&amp;amp;/g&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; -r u&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;%s %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; wc -c&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The plugin stylesheets had a few kilobytes each. The theme&amp;rsquo;s own, &lt;code&gt;type=scss&lt;/code&gt;, had zero bytes.&lt;/p&gt;&#10;&#10;&lt;h2 id="getting-the-real-error"&gt;Getting the real error &lt;/h2&gt;&#10;&lt;p&gt;Moodle catches the compiler&amp;rsquo;s exception and passes the message to &lt;code&gt;debugging()&lt;/code&gt; at developer level, so at the usual debug setting you get nothing at all. You don&amp;rsquo;t have to change the site&amp;rsquo;s settings to see it. Compile the theme from the CLI with debugging switched on for that one process (run it as the web server&amp;rsquo;s user and adjust the path to &lt;code&gt;config.php&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo -u www-data php -r &lt;span class="s1"&gt;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;define(&amp;#34;CLI_SCRIPT&amp;#34;, 1);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;require &amp;#34;/opt/moodle/config.php&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$CFG-&amp;gt;debug = E_ALL;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$CFG-&amp;gt;debugdisplay = 1;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$t = \core\output\theme_config::load(&amp;#34;classic&amp;#34;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$m = new ReflectionMethod($t, &amp;#34;get_css_content_from_scss&amp;#34;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$c = $m-&amp;gt;invoke($t, false);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;var_dump(is_string($c) ? strlen($c) : $c);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;get_css_content_from_scss&lt;/code&gt; isn&amp;rsquo;t public, hence the reflection. It returns &lt;code&gt;false&lt;/code&gt; when the compile fails, and the error comes with the import chain:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;++ Error while compiling SCSS: Undefined variable $mds-spacing-md: /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle/course.scss on line 132, at column 17&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Call Stack:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#0 import /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle/course.scss /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle.scss on line 13&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#1 import /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle.scss /opt/moodle/public/theme/classic/scss/moodle.scss on line 2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#2 import /opt/moodle/public/theme/classic/scss/moodle.scss (unknown file) on line 127 ++&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;Boost&amp;rsquo;s default preset, &lt;code&gt;theme/boost/scss/preset/default.scss&lt;/code&gt;, starts with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-scss" data-lang="scss"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Import Moodle Design System tokens.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Must be imported before Bootstrap so that the design system tokens can be used to override Bootstrap variables.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;design-system&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;design-system.scss&lt;/code&gt; pulls in the &lt;code&gt;$mds-*&lt;/code&gt; tokens. Classic&amp;rsquo;s presets import &lt;code&gt;fontawesome&lt;/code&gt;, &lt;code&gt;bootstrap&lt;/code&gt; and &lt;code&gt;moodle&lt;/code&gt;, but not &lt;code&gt;design-system&lt;/code&gt;, and Classic&amp;rsquo;s &lt;code&gt;moodle.scss&lt;/code&gt; imports Boost&amp;rsquo;s. So Boost&amp;rsquo;s &lt;code&gt;course.scss&lt;/code&gt; uses &lt;code&gt;$mds-spacing-md&lt;/code&gt;, and nothing has defined it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Add the import to the top of &lt;code&gt;theme/classic/scss/classic/pre.scss&lt;/code&gt;. Classic&amp;rsquo;s &lt;code&gt;lib.php&lt;/code&gt; puts that file in front of the preset, so this covers both presets:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;pre&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/moodle/public/theme/classic/scss/classic/pre.scss&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;@import &amp;#34;../../boost/scss/design-system&amp;#34;;\n\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; cat &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$pre&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &amp;gt; /tmp/pre.scss&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mv /tmp/pre.scss &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$pre&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo -u www-data php /opt/moodle/admin/cli/purge_caches.php --theme&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The path resolves from &lt;code&gt;theme/classic/scss/&lt;/code&gt;, as the call stack above shows. After the purge, the same CLI compile returned 1,218,070 characters instead of &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;styles_debug.php&lt;/code&gt; sent 1,557,703 bytes.&lt;/p&gt;&#10;&lt;p&gt;If Classic is part of your build, as here, apply the patch in the build. A fix made inside a running container is gone the next time the container is recreated.&lt;/p&gt;&#10;&lt;p&gt;Once the layout was back, I turned theme designer mode off (&lt;code&gt;admin/cli/cfg.php --name=themedesignermode --set=0&lt;/code&gt;). It recompiles the theme on every page view: here, about 0.8 s and 112 stylesheet requests per page.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A Moodle theme that fails to compile still answers 200, just with no CSS, so upgrade checks that look only at status codes pass right over it.&lt;/p&gt;&#10;&lt;p&gt;Before your next Moodle upgrade, add the theme&amp;rsquo;s compiled CSS size to your checklist: anything near zero is a failed compile.&lt;/p&gt;&#10;</description></item><item><title>A five-second Swift Testing wait timed out after its loop had run five times</title><link>https://eghuro.cz/posts/swift-testing-wait-until-main-actor-starvation/</link><pubDate>Thu, 22 Oct 2026 16:12:00 +0200</pubDate><guid>https://eghuro.cz/posts/swift-testing-wait-until-main-actor-starvation/</guid><description>&lt;p&gt;Five concurrency tests passed on their own in 0.086 seconds. In the full target, they failed together at about 6.8 seconds:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test fetch_threeSources_asksForEverySourceAtOnce() recorded an issue at FetcherConcurrencyTests.swift:21:30:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Expectation failed: ContinuousClock.now &amp;lt; deadline&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test fetch_threeSources_asksForEverySourceAtOnce() failed after 6.816 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It looked like a deadlock in the code under test. It was a test helper measuring the wrong thing.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Swift Testing, an Xcode test target that mixes &lt;code&gt;@MainActor&lt;/code&gt; unit suites with snapshot suites.&lt;/li&gt;&#10;&lt;li&gt;Two test plans run the same target. One sets &lt;code&gt;&amp;quot;parallelizable&amp;quot;: false&lt;/code&gt; for it, and the other doesn&amp;rsquo;t. Only the parallel plan failed.&lt;/li&gt;&#10;&lt;li&gt;A small polling helper that the concurrent-fetch tests use to wait for a gated fake service to see requests:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SourceLocation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;_sourceLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;Timed out waiting for the condition to hold&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The helper runs on the main actor. Each &lt;code&gt;Task.yield()&lt;/code&gt; lets the queued work, in this case the fetcher opening its requests, run before the condition is checked again.&lt;/p&gt;&#10;&#10;&lt;h2 id="reproduce-before-guessing"&gt;Reproduce before guessing &lt;/h2&gt;&#10;&lt;p&gt;Running the target alone under the parallel plan with &lt;code&gt;-test-iterations&lt;/code&gt; and &lt;code&gt;-run-tests-until-failure&lt;/code&gt; failed 3 out of 3 times, always in the same tests and always at the wall-clock deadline. So it wasn&amp;rsquo;t a rare flake. It failed every time under that plan.&lt;/p&gt;&#10;&lt;p&gt;To check whether the waits were stuck or just slow, I temporarily replaced the deadline with a counter and printed what each wait used:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;turns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;WAITPROBE turns=&lt;/span&gt;&lt;span class="si"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s"&gt; elapsed=&lt;/span&gt;&lt;span class="si"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;5 5.766 FetcherConcurrencyTests.swift:54&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;5 5.712 FetcherConcurrencyTests.swift:21&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;4 6.411 FetcherConcurrencyTests.swift:37&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3 5.704 SyncLoggingTests.swift:25&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3 5.338 InFlightTests.swift:20&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every wait finished, but the slowest needed only &lt;strong&gt;3 to 5 turns&lt;/strong&gt; and took &lt;strong&gt;more than 5 seconds&lt;/strong&gt; of wall time. The condition was met on almost the first chance the fetcher got to run. It just didn&amp;rsquo;t get that chance for seconds.&lt;/p&gt;&#10;&lt;p&gt;The reason showed up when I sorted tests by duration. In the parallel plan, snapshot suites share the process and the main actor, and each render holds the actor synchronously, some of them for over 7 seconds. While a render ran, the waiting test&amp;rsquo;s next turn just sat in the queue, and the clock kept running.&lt;/p&gt;&#10;&#10;&lt;h2 id="dead-end-a-longer-timeout"&gt;Dead end: a longer timeout &lt;/h2&gt;&#10;&lt;p&gt;I tried a 60-second deadline as an experiment. Both runs passed, with the concurrency tests taking about 7.9 seconds each.&lt;/p&gt;&#10;&lt;p&gt;That shows the cause, but it isn&amp;rsquo;t a fix. 60 is no more principled than 5. A heavier snapshot suite brings the failure back, and a test that really hangs now takes a minute to report it. I reverted it.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-spend-both-budgets-before-failing"&gt;Fix: spend both budgets before failing &lt;/h2&gt;&#10;&lt;p&gt;The wait should fail only once it has had enough chances to make progress and enough time has passed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SourceLocation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;_sourceLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;turns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hasBudgetLeft&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;turnBudget&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasBudgetLeft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;Timed out waiting for the condition to hold&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// A wait under a fully loaded run of this target took at most 5 turns.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;turnBudget&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On an idle main actor, 100 turns go by in milliseconds, so a condition that never holds still fails just after the time budget. On a starved actor, the wait keeps going until it has had real chances to run.&lt;/p&gt;&#10;&#10;&lt;h2 id="pin-it-with-a-controlled-starvation-test"&gt;Pin it with a controlled starvation test &lt;/h2&gt;&#10;&lt;p&gt;The full target is a slow, noisy reproduction, so I wrote a test that intentionally causes starvation. It makes &lt;code&gt;timeBudget&lt;/code&gt; a parameter, holds the main actor for longer than that budget, and makes the condition true only afterward:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;MainActor&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Suite&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;WaitTests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Test&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until_whenTheMainActorIsHeldPastTheTimeBudget_waitsForTheCondition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;holds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LockIsolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// stands in for a snapshot render&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;milliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Test&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until_whenTheConditionNeverHolds_fails&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;withKnownIssue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before the fix, it failed after 0.335 seconds with the same &lt;code&gt;ContinuousClock.now &amp;lt; deadline&lt;/code&gt; error. After the fix, it passed in 0.303 seconds. The second test makes sure the helper can still fail.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-both-directions"&gt;Verify both directions &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Check&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Result&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Parallel plan, before the fix&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0 of 3 passed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Parallel plan, after the fix&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;5 of 5 passed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Serial plan, whole target&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;690 tests passed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mutant: fetcher limited to one request at a time&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;all 5 concurrency tests failed, each at 5.0 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The last row matters most. A longer timeout would also have made the parallel runs pass. What it wouldn&amp;rsquo;t keep is fast failure for a real bug. I changed the fetcher&amp;rsquo;s concurrency limit from 4 to 1, so the &amp;ldquo;all three requests are open at once&amp;rdquo; condition could never be true. All five tests caught it, each at the 5-second time budget, and I reverted the mutation.&lt;/p&gt;&#10;&lt;p&gt;One caveat: that mutant ran under the serial plan. Under heavy parallel load, a real hang fails after 100 turns, which takes longer than 5 seconds. That&amp;rsquo;s the trade-off I chose, and it applies to this helper and this workload. It isn&amp;rsquo;t a general fairness guarantee for &lt;code&gt;Task.yield()&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-second-guess-hiding-in-the-same-tests"&gt;A second guess hiding in the same tests &lt;/h2&gt;&#10;&lt;p&gt;Three tests waited for the &lt;em&gt;first&lt;/em&gt; request and then gave the scheduler &amp;ldquo;five settling turns&amp;rdquo; before asserting that all three were open. That turn count was another guess, and it could fail under load the same way. The fix was to wait for the exact condition:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untilSettled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// after&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A wall-clock timeout in a polling helper measures how busy the process is, not whether your code is stuck. When a wait times out only in the big parallel run, count its turns before you suspect a deadlock. If it got only a handful of turns, the actor was starved. Make the helper fail only after both a time budget and a turn budget run out. Then check the fix with a mutation that should hang and make sure it still fails quickly.&lt;/p&gt;&#10;&lt;p&gt;Try it on your own polling helper: add a turn counter and run the full target in parallel once. If the worst wait used single-digit turns but took seconds, the timeout is measuring main-actor contention.&lt;/p&gt;&#10;</description></item><item><title>rsync code 23: atomic archives can still form an incomplete release</title><link>https://eghuro.cz/posts/rsync-atomic-archives-incomplete-release/</link><pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/rsync-atomic-archives-incomplete-release/</guid><description>&lt;p&gt;A dataset transfer ended with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1327) [sender=3.2.5]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The packing job published each finished archive by rename, so final filenames never exposed an archive still being written. The transfer nevertheless began before the collection was complete. Repeating it after packing finished, then checking destination hashes, recovered the release.&lt;/p&gt;&#10;&lt;p&gt;This happened in the &lt;a&#10; href="https://eghuro.cz/posts/claude-code-ssh-openpbs-setup"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;Claude Code over SSH and OpenPBS workflow&lt;/a&gt;&#10;: a PBS job produced the archives, while separate remote commands and a human-operated transfer consumed them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-filename-was-safe-before-the-collection-was-ready"&gt;The filename was safe before the collection was ready &lt;/h2&gt;&#10;&lt;p&gt;The packer used this pattern, with paths generalized:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar -czf groups_00.tar.gz.part groups/part_00 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; mv groups_00.tar.gz.part groups_00.tar.gz&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A reader looking for &lt;code&gt;*.tar.gz&lt;/code&gt; would only see that archive after its successful creation and rename on the same filesystem. But the producer repeated the operation for several archives, then wrote sidecar files. Nothing prevented a consumer from enumerating the directory between those steps.&lt;/p&gt;&#10;&lt;p&gt;Transfer instructions went out while the packer was running. The user&amp;rsquo;s pasted output ended with &lt;code&gt;to-chk=0/16&lt;/code&gt;—the intended release contained seventeen archives plus a checksum manifest and a guide. A later source listing showed the complete set.&lt;/p&gt;&#10;&lt;p&gt;The count, transferred size, and packing timeline supported the explanation that the last archive and sidecars didn&amp;rsquo;t exist when the transfer enumerated its inputs. The earlier detailed rsync errors were not included, so this remained a supported diagnosis rather than a direct readback of each missing-path error.&lt;/p&gt;&#10;&lt;p&gt;Code 23 alone cannot identify that cause. The &lt;a&#10; href="https://download.samba.org/pub/rsync/rsync.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;rsync manual&lt;/a&gt;&#10; defines it as a partial transfer due to error; inspect the preceding errors before retrying mindlessly.&lt;/p&gt;&#10;&#10;&lt;h2 id="repeat-the-transfer-after-checking-the-producer"&gt;Repeat the transfer after checking the producer &lt;/h2&gt;&#10;&lt;p&gt;The recovery checked that packing had finished, repeated the transfer, and verified the archives at the destination. With server names and filenames generalized, run from the producer&amp;rsquo;s archive directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync -rtv --partial --progress &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; SHA256SUMS guide.txt groups_*.tar.gz metadata.tar.gz &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; researcher@192.168.0.3:incoming/release-01/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then, on the destination:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/incoming/release-01 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sha256sum -c SHA256SUMS&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The user supplied seventeen &lt;code&gt;OK&lt;/code&gt; results. Those checks verified the listed archive bytes, including the archives transferred earlier. They did not verify recipient permissions or the guide&amp;rsquo;s contents. A separate archive-content check also lacked &lt;code&gt;bc&lt;/code&gt;, so its aggregate group-count calculation did not succeed; that check must not be presented as another passing validation.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;--partial&lt;/code&gt; retains partial transfers. It does not by itself promise efficient offset-based resumption under every transfer configuration.&lt;/p&gt;&#10;&#10;&lt;h2 id="add-a-release-complete-barrier"&gt;Add a release-complete barrier &lt;/h2&gt;&#10;&lt;p&gt;We did not implement a stronger publication protocol during this recovery. For a future run, use a fresh release directory that consumers cannot mistake for an earlier finished release. Publish a completion marker only after validating the expected archive set and sidecars. Consumers should require that marker before enumeration and check hashes after transfer.&lt;/p&gt;&#10;&lt;p&gt;A marker needs an immutable release behind it. Reusing a directory with an old marker can announce incomplete new contents as ready; writing the marker early merely moves the race.&lt;/p&gt;&#10;&lt;p&gt;Before sending a transfer command, check the producer&amp;rsquo;s completion evidence and the expected file set. Per-file renames protect readers of each file, but consumers still need a defined point at which the collection is ready.&lt;/p&gt;&#10;</description></item><item><title>Unprivileged LXC containers share the host's per-user kernel limits: 'Too many open files' and 'disk quota exceeded'</title><link>https://eghuro.cz/posts/lxc-containers-share-host-kernel-limits/</link><pubDate>Wed, 21 Oct 2026 13:13:00 +0200</pubDate><guid>https://eghuro.cz/posts/lxc-containers-share-host-kernel-limits/</guid><description>&lt;p&gt;One day, two containers on the same host failed with errors that seemed unrelated. systemd in one of them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[4573]: Failed to create control group inotify object: Too many open files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[4573]: Failed to allocate manager object: Too many open files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[1]: Failed to start user@1000.service - User Manager for UID 1000.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Docker in another, where every container sat at &lt;code&gt;Exited (128)&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OCI runtime create failed: runc create failed: unable to start container process: error during container init: unable to join session keyring: unable to create session key: disk quota exceeded: unknown&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No file descriptors had run out, and no disk was full. Both errors come from per-user kernel quotas counted on the host, and every unprivileged container there was drawing from the same two.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, kernel 6.12, LXC 6.0.4&lt;/li&gt;&#10;&lt;li&gt;More unprivileged containers than the hardware comfortably runs, all with the same idmap: container root is host UID 1000000&lt;/li&gt;&#10;&lt;li&gt;systemd in every container, Docker nested in some&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="too-many-open-files-inotify-instances"&gt;Too many open files: inotify instances &lt;/h2&gt;&#10;&lt;p&gt;systemd opens inotify instances to watch cgroups and units, and &lt;code&gt;inotify_init1()&lt;/code&gt; fails with &lt;code&gt;EMFILE&lt;/code&gt; when the user&amp;rsquo;s instance limit is reached. strerror turns that into &amp;ldquo;Too many open files&amp;rdquo;. The limit is &lt;code&gt;fs.inotify.max_user_instances&lt;/code&gt;, 128 by default.&lt;/p&gt;&#10;&lt;p&gt;Count the instances per user on the host:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo find /proc/&lt;span class="o"&gt;[&lt;/span&gt;0-9&lt;span class="o"&gt;]&lt;/span&gt;*/fd -lname &lt;span class="s1"&gt;&amp;#39;anon_inode:inotify&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; cut -d/ -f3 &lt;span class="p"&gt;|&lt;/span&gt; xargs -I&lt;span class="o"&gt;{}&lt;/span&gt; ps -o &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; -p &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &lt;span class="p"&gt;|&lt;/span&gt; uniq -c &lt;span class="p"&gt;|&lt;/span&gt; sort -rn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /proc/sys/fs/inotify/max_user_instances&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Host root held 23 instances, and uids in the container range (1000000 and up) held 104 between them: 127 of 128. On the first sighting, my coding agent got that far, then dropped it as a side effect of having started too many containers at once. That was half right, since each container&amp;rsquo;s systemd adds about five instances. A day later it came back.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;lxc&lt;/code&gt; package already ships a higher limit, and it wasn&amp;rsquo;t in effect:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /etc/sysctl.d/30-lxc-inotify.conf | grep -v &amp;#39;^#&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fs.inotify.max_user_instances = 1024&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /proc/sys/fs/inotify/max_user_instances&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;128&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file was dated three days after the last boot. &lt;code&gt;/etc/sysctl.d&lt;/code&gt; is applied by &lt;code&gt;systemd-sysctl&lt;/code&gt; at boot, and the package&amp;rsquo;s install hadn&amp;rsquo;t applied it, so the setting sat on disk doing nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="disk-quota-exceeded-kernel-keys"&gt;Disk quota exceeded: kernel keys &lt;/h2&gt;&#10;&lt;p&gt;runc gives each container it starts a new session keyring. &lt;code&gt;add_key()&lt;/code&gt; fails with &lt;code&gt;EDQUOT&lt;/code&gt; when the user&amp;rsquo;s key quota is full, and strerror calls that &amp;ldquo;Disk quota exceeded&amp;rdquo;. The quota is &lt;code&gt;kernel.keys.maxkeys&lt;/code&gt;, 200 keys for any uid other than root. The host shows usage per UID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /proc/key-users&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0: 127 126/126 107/1000000 2283/25000000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1000: 12 12/12 12/200 86/20000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1000000: 198 198/198 198/200 4189/20000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;198/200&lt;/code&gt; are keys used against the quota. UID 1000000 is root in every container, so all the containers shared those 200 keys, and Docker&amp;rsquo;s restart attempts had nothing left.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Raise the limits and apply them right away, not at the next boot. Incus recommends 2000 keys and 2 MB in its &lt;a&#10; href="https://linuxcontainers.org/incus/docs/main/reference/server_settings"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;server settings&lt;/a&gt;&#10;; 1024 inotify instances matches the &lt;code&gt;lxc&lt;/code&gt; package.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo tee /etc/sysctl.d/60-lxc-limits.conf &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;fs.inotify.max_user_instances = 1024&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;kernel.keys.maxkeys = 2000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;kernel.keys.maxbytes = 2000000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;EOF&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo sysctl -p /etc/sysctl.d/60-lxc-limits.conf&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With Ansible, &lt;code&gt;ansible.posix.sysctl&lt;/code&gt; with &lt;code&gt;sysctl_set: true&lt;/code&gt; writes the file and sets the live value in one task.&lt;/p&gt;&#10;&lt;p&gt;Then run &lt;code&gt;systemctl reset-failed&lt;/code&gt; in the affected container, and &lt;code&gt;docker start&lt;/code&gt; for the dead Docker containers. Right after, uid 1000000 held 226 keys, already over the old limit.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-two-quotas-are-counted-differently"&gt;Why the two quotas are counted differently &lt;/h2&gt;&#10;&lt;p&gt;The two quotas look similar, but separate idmaps would fix only one.&lt;/p&gt;&#10;&lt;p&gt;Keys are counted per host UID, against global sysctls. Containers that share an idmap share one quota. A separate idmap per container (&lt;code&gt;security.idmap.isolated&lt;/code&gt; in LXD and Incus) would give each its own.&lt;/p&gt;&#10;&lt;p&gt;inotify instances are counted per user namespace and charged up the chain of parent namespaces, at each level against the uid that created the child namespace. A new namespace&amp;rsquo;s own limit is &lt;code&gt;INT_MAX&lt;/code&gt;, so the limit that bites is the host&amp;rsquo;s, charged to whoever started the containers. LXC started as root charges host root, which is why the 23 instances of host root&amp;rsquo;s own daemons counted toward the same 128. Separate idmaps don&amp;rsquo;t change that.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On a container host, &amp;ldquo;Too many open files&amp;rdquo; and &amp;ldquo;disk quota exceeded&amp;rdquo; can mean that every container together hit a per-user kernel quota on the host. Check the host&amp;rsquo;s counters before you debug the container that complained.&lt;/p&gt;&#10;&lt;p&gt;Run the &lt;code&gt;find&lt;/code&gt; count and &lt;code&gt;cat /proc/key-users&lt;/code&gt; on your own container host and compare them with the limits before a container restart finds out for you.&lt;/p&gt;&#10;</description></item><item><title>OpenPBS array resume: shard by stable task ID</title><link>https://eghuro.cz/posts/openpbs-resume-stable-shard-ids/</link><pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/openpbs-resume-stable-shard-ids/</guid><description>&lt;p&gt;A resumable OpenPBS array can assign the same task to different workers if each worker slices its own remaining-work list. The unsafe implementation was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;We caught this before the array ran and changed ownership to &lt;code&gt;group_id % worker_count&lt;/code&gt;. The corrected workers later started at different times and restarted with fewer tasks remaining; the final audit accounted for every group.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://eghuro.cz/posts/claude-code-ssh-openpbs-setup"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;Claude Code, SSH, and OpenPBS setup&lt;/a&gt;&#10; explains where commands ran and how jobs were submitted. This failure concerns application-level partitioning, not PBS assigning one array index twice.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-pending-list-changes-between-starts"&gt;A pending list changes between starts &lt;/h2&gt;&#10;&lt;p&gt;The workload was moving from a multi-GPU job to four independently scheduled one-GPU array elements. Each worker skipped groups with stored completion or rejection markers, then selected a shard.&lt;/p&gt;&#10;&lt;p&gt;Suppose the immutable task IDs are zero through five. Worker zero starts first and takes every second pending entry:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pending: 0 1 2 3 4 5&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker 0: 0 2 4&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It finishes task zero before worker one starts. Worker one&amp;rsquo;s view is now:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pending: 1 2 3 4 5&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker 1: 2 4&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Tasks two and four overlap, while one, three, and five have no owner in that pair of snapshots. Restarting workers creates the same problem. A completion marker can filter old work without stabilizing the positions of everything that remains.&lt;/p&gt;&#10;&#10;&lt;h2 id="preserve-ownership-while-filtering-completion"&gt;Preserve ownership while filtering completion &lt;/h2&gt;&#10;&lt;p&gt;The actual correction retained the immutable numeric group ID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;group&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Equivalently, make the two decisions explicit:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;owned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;group&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In this application, &lt;code&gt;--shard I/N&lt;/code&gt; supplied &lt;code&gt;i&lt;/code&gt; and &lt;code&gt;n&lt;/code&gt;. The submitted script used &lt;code&gt;#PBS -J 0-3&lt;/code&gt; and passed &lt;code&gt;--shard &amp;quot;$PBS_ARRAY_INDEX/4&amp;quot;&lt;/code&gt; to the project-specific driver. Each element requested one GPU. Smaller resource requests did not guarantee immediate starts; later queue snapshots still showed waiting elements.&lt;/p&gt;&#10;&lt;p&gt;Keep the shard count fixed for a running generation. Changing &lt;code&gt;n&lt;/code&gt; changes ownership even with stable IDs. Also, the same shard launched twice still has overlapping ownership; see the separate &lt;a&#10; href="https://eghuro.cz/posts/resumable-worker-concurrent-launch"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;concurrent-launch failure&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-different-snapshots-then-audit-the-run"&gt;Test different snapshots, then audit the run &lt;/h2&gt;&#10;&lt;p&gt;This small Python check reproduces the bad assignment and checks the correction:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;all_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;first_pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;all_ids&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;later_pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;all_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;bad0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_pending&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;bad1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;later_pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;bad0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;bad1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;owned0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;first_pending&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;owned1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;later_pending&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;owned0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;owned1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;owned0&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;owned1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;stable ownership checks passed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The production evidence was broader than a queue disappearing: logs showed staggered starts, three workers restarting with smaller pending counts, and accepted/rejected outcomes covering the design. A final check reported:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;missing (neither): []&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That established outcome coverage. It was not a separate duplicate-output audit, and the logs did not establish why those workers restarted. Atomic replacement of a shared CSV also does not prove its concurrent snapshots were consistent.&lt;/p&gt;&#10;&lt;p&gt;Before your next resumable array, test ownership against two different completion snapshots. Every unfinished task should retain its owner regardless of when that owner starts.&lt;/p&gt;&#10;</description></item><item><title>"No route to host" only for the AI agent: macOS Local Network privacy</title><link>https://eghuro.cz/posts/macos-local-network-privacy-agent-no-route-to-host/</link><pubDate>Sat, 17 Oct 2026 22:30:00 +0200</pubDate><guid>https://eghuro.cz/posts/macos-local-network-privacy-agent-no-route-to-host/</guid><description>&lt;p&gt;My coding agent couldn&amp;rsquo;t reach anything on my home network:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nc: connectx to 192.168.0.2 port 22 (tcp) failed: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ping: sendto: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The router answered, and &lt;code&gt;arp -an&lt;/code&gt; listed the server&amp;rsquo;s MAC address. In my own Terminal, the same &lt;code&gt;ssh&lt;/code&gt; command connected. If you see this on a Mac and only your agent sees it, your network is fine. macOS is blocking the app the agent runs in because that app doesn&amp;rsquo;t have the Local Network permission.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS 27 on Wi-Fi&lt;/li&gt;&#10;&lt;li&gt;Claude Code in its VS Code extension, so every command the agent runs is a child process of VS Code&lt;/li&gt;&#10;&lt;li&gt;Targets on the Mac&amp;rsquo;s own subnet: a Linux server, a NAS, a second router&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The agent was helping me with a server that really was down (&lt;a&#10; href="https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;that story is here&lt;/a&gt;&#10;). It probed the subnet from the Mac, found that only the router answered, and reported with confidence that &amp;ldquo;the Mac reaching only [the router] looks like client isolation on the Wi-Fi. That&amp;rsquo;s usually a guest network.&amp;rdquo; It told me to join a different network.&lt;/p&gt;&#10;&lt;p&gt;I wasn&amp;rsquo;t on a guest network, and I said so. The agent looked again and noticed what rules the network out: &lt;code&gt;ping&lt;/code&gt; itself failed with &lt;code&gt;sendto: No route to host&lt;/code&gt;. That message comes from the local &lt;code&gt;sendto()&lt;/code&gt; call, so the Mac refused to send the packet at all. Client isolation drops packets in the access point, and then &lt;code&gt;ping&lt;/code&gt; reports &lt;code&gt;Request timeout&lt;/code&gt;, not a send error. The target&amp;rsquo;s MAC was already in the ARP table, so the kernel knew exactly where to send it.&lt;/p&gt;&#10;&lt;p&gt;So the block was at the MAC, and every TCP test the agent ran from there was worthless.&lt;/p&gt;&#10;&lt;p&gt;Later that evening, in a new session, it happened again with a different host on the same subnet. This time the agent suspected its own sandbox and retried with the sandbox off. Same error. What settled it was my reply: the same &lt;code&gt;ssh&lt;/code&gt; command works in my Terminal.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Open System Settings → Privacy &amp;amp; Security → Local Network and turn on Visual Studio Code (or whichever app hosts your agent). The agent&amp;rsquo;s next &lt;code&gt;ssh&lt;/code&gt; went through. If yours still fails, restart the app.&lt;/p&gt;&#10;&lt;p&gt;The app can be missing from the list if macOS never showed it the permission prompt. Apple&amp;rsquo;s &lt;a&#10; href="https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;TN3179&lt;/a&gt;&#10; notes that the prompt doesn&amp;rsquo;t appear when a very short-lived process makes the request, and most agent commands exit right after they fail. Since macOS 27.2, the list includes a + button to add an app yourself.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-only-the-agent"&gt;Why only the agent &lt;/h2&gt;&#10;&lt;p&gt;Local Network privacy covers traffic to addresses on a Wi-Fi or Ethernet network that goes directly to the host, not through a router. TN3179 lists who gets access without asking:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;daemons started by &lt;code&gt;launchd&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;programs running as root&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;command-line tools run from Terminal or over SSH, including any child processes they spawn&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Everything else is charged to its &amp;ldquo;responsible code&amp;rdquo;. If an app spawns a helper and the helper connects to a local address, macOS checks the app&amp;rsquo;s permission. The agent&amp;rsquo;s shell is a child of VS Code, so &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;nc&lt;/code&gt;, and &lt;code&gt;ping&lt;/code&gt; run there under VS Code&amp;rsquo;s permission. The same binary, run by the same user from Terminal, is allowed.&lt;/p&gt;&#10;&lt;p&gt;That explains the other half of the symptom. Internet traffic goes through the router, so it isn&amp;rsquo;t local and works fine. TN3179 also exempts the device&amp;rsquo;s DNS server when it sits on the local network, and a home router is usually the DNS server it hands out by DHCP. That is probably why the router was the one host still reachable, and it is exactly how client isolation looks from the inside.&lt;/p&gt;&#10;&lt;p&gt;The same trap waits for any agent or tool that an app such as an IDE starts on macOS.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-before-you-blame-the-network"&gt;Check before you blame the network &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Run &lt;code&gt;ping&lt;/code&gt; from the agent&amp;rsquo;s shell. &lt;code&gt;sendto: No route to host&lt;/code&gt; for a host on your subnet whose MAC is in &lt;code&gt;arp -an&lt;/code&gt; means the Mac refused to send it.&lt;/li&gt;&#10;&lt;li&gt;Run the same command in Terminal. If it works there, check the app&amp;rsquo;s Local Network permission.&lt;/li&gt;&#10;&lt;li&gt;If it fails in Terminal too, the cause is the network or the routing table. Later that same evening, another VPN came up as &lt;code&gt;utun6&lt;/code&gt; and took the default route, which cut the whole Mac off from the subnet. This time the agent caught it in &lt;code&gt;netstat -rn -f inet&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On macOS, an app without the Local Network permission gets &lt;code&gt;No route to host&lt;/code&gt; for hosts on your subnet, and so does every tool it spawns. An agent running inside that app will diagnose a working network as broken.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs inside an app on macOS, open Privacy &amp;amp; Security → Local Network now and check that the app is on the list and switched on.&lt;/p&gt;&#10;</description></item><item><title>The rollback that saved the host, and killed it 24 hours later</title><link>https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient/</link><pubDate>Sat, 17 Oct 2026 21:51:00 +0200</pubDate><guid>https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient/</guid><description>&lt;p&gt;The rollback timer fired, the remote host got its address back, and the journal said all was well:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dhclient[1796120]: bound to 192.168.0.2 -- renewal in 34318 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[1]: lxc-uplink-rollback.service: Deactivated successfully.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exactly 24 hours later, to the second, the host dropped off the network and took SSH, the port forwards, and the VPN with it.&lt;/p&gt;&#10;&lt;p&gt;When a transient &lt;code&gt;systemd-run&lt;/code&gt; unit finishes, systemd kills everything its command left running. If your rollback, or any other command run that way, starts a daemon, the daemon dies with the unit. Here it was &lt;code&gt;dhclient&lt;/code&gt;, and the damage only showed when the DHCP lease expired.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, systemd 257, ifupdown with ISC dhclient 4.4.3-P1&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;allow-hotplug enp0s25&lt;/code&gt; / &lt;code&gt;iface enp0s25 inet dhcp&lt;/code&gt;, with a 24-hour lease from the router&lt;/li&gt;&#10;&lt;li&gt;The only way in is SSH through a port forward to the host&amp;rsquo;s address; the console is days away&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="the-dead-mans-switch-worked"&gt;The dead man&amp;rsquo;s switch worked &lt;/h2&gt;&#10;&lt;p&gt;Before a risky network change, an Ansible play arms a timer that undoes it, applies the change, and disarms the timer only if a fresh SSH login succeeds:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Arm rollback timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;-&lt;/span&gt;&lt;span class="sd"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; systemd-run --collect --unit=lxc-uplink-rollback --on-active=5min&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; /bin/sh -c &amp;#39;ifdown -a --exclude=lo; ip link del br0;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; cp /etc/network/interfaces.rollback /etc/network/interfaces;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; ifup -a; ifup -a --allow=hotplug&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# ... apply the change ...&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Drop the cached SSH connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;reset_connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Prove a fresh SSH login still works&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.wait_for_connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;420&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Disarm rollback timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;systemctl stop lxc-uplink-rollback.timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without &lt;code&gt;meta: reset_connection&lt;/code&gt;, Ansible reuses its open SSH connection and a broken change looks fine.&lt;/p&gt;&#10;&lt;p&gt;The change moved the NIC into a bridge, &lt;code&gt;br0&lt;/code&gt;. The bridge got the old address back, but forwarded SSH from the internet stopped reaching the host. (I never found out why, and it isn&amp;rsquo;t the point here.) Five minutes later the timer restored the old config, the host came back, and I moved on.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;Look at the rollback unit again. Its main process is &lt;code&gt;/bin/sh&lt;/code&gt;. &lt;code&gt;ifup&lt;/code&gt; starts &lt;code&gt;dhclient&lt;/code&gt;, which forks into the background, and then &lt;code&gt;sh&lt;/code&gt; exits. The unit is now done, and a finished service gets its cgroup cleaned up: by default (&lt;code&gt;KillMode=control-group&lt;/code&gt;), systemd kills every process still in it. That includes the &lt;code&gt;dhclient&lt;/code&gt; that had just been &amp;ldquo;bound&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;A killed &lt;code&gt;dhclient&lt;/code&gt; doesn&amp;rsquo;t release the lease, so the address stays on the interface with a lifetime of 86400 seconds. Nothing renews it. When the lifetime runs out, the kernel removes the address, and avahi is the first to notice:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Oct 17 21:08:42 avahi-daemon[936]: Withdrawing address record for 192.168.0.2 on enp0s25.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The clue was in the journal on day one. When the rollback started, its &lt;code&gt;dhclient&lt;/code&gt; logged:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dhclient[1796009]: Removed stale PID file&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That stale PID file belonged to the &lt;code&gt;dhclient&lt;/code&gt; on &lt;code&gt;br0&lt;/code&gt;. Ansible had started it five minutes earlier from another &lt;code&gt;systemd-run&lt;/code&gt; unit, and systemd had killed it the same way, but the rollback replaced it before anyone noticed.&lt;/p&gt;&#10;&lt;p&gt;At boot, &lt;code&gt;ifup@enp0s25.service&lt;/code&gt; starts the same &lt;code&gt;dhclient&lt;/code&gt;. That unit stays &lt;code&gt;active (exited)&lt;/code&gt;, so its cgroup is never cleaned up, which is how the boot-time &lt;code&gt;dhclient&lt;/code&gt; had kept renewing for about six weeks.&lt;/p&gt;&#10;&lt;p&gt;You can see the behavior in two lines:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemd-run --unit&lt;span class="o"&gt;=&lt;/span&gt;demo /bin/sh -c &lt;span class="s1"&gt;&amp;#39;sleep 600 &amp;amp;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sleep 1&lt;span class="p"&gt;;&lt;/span&gt; pgrep -a sleep &lt;span class="c1"&gt;# nothing: systemd killed it when sh exited&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;To get back in, I went through a VM that had its own macvlan leg on the same NIC and ran &lt;code&gt;sudo dhclient enp0s25&lt;/code&gt;. With &lt;code&gt;ssh -J&lt;/code&gt;, &lt;code&gt;-i&lt;/code&gt; applies only to the last hop, so give the jump host its key through &lt;code&gt;ProxyCommand&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh -i ~/.ssh/key -o &lt;span class="nv"&gt;IdentitiesOnly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yes &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -o &lt;span class="nv"&gt;ProxyCommand&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;ssh -i ~/.ssh/key -o IdentitiesOnly=yes -W %h:%p user@jump.example.com&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; user@target.example.com&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The real fix was to stop depending on a long-running DHCP client. The router reserves the address for the host anyway, so the host now has a static stanza. Ansible applies it without restarting networking, because that would cut off the run and the only way in:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ip address replace 192.168.0.2/24 dev enp0s25 valid_lft forever preferred_lft forever&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ip route replace default via 192.168.0.1 dev enp0s25&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pkill --signal KILL -f &lt;span class="s1"&gt;&amp;#39;(^|/)dhclient .*enp0s25$&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;SIGKILL&lt;/code&gt; is deliberate: a &lt;code&gt;dhclient&lt;/code&gt; that stops cleanly removes the address it holds, the one this very run is connected through.&lt;/p&gt;&#10;&lt;p&gt;If the host has to stay on DHCP, keep daemons out of transient units. Start the daemon through the unit that owns it at boot (here &lt;code&gt;ifup@enp0s25.service&lt;/code&gt;), or let the transient unit leave its children alone with &lt;code&gt;systemd-run -p KillMode=process&lt;/code&gt;. I didn&amp;rsquo;t retry the bridge, so I haven&amp;rsquo;t tested either variant in this rollback.&lt;/p&gt;&#10;&lt;p&gt;To check which unit a daemon really belongs to:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /proc/&lt;span class="k"&gt;$(&lt;/span&gt;pgrep -o dhclient&lt;span class="k"&gt;)&lt;/span&gt;/cgroup&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Deactivated successfully&lt;/code&gt; only says the unit finished; whatever it started may have died with it. A killed DHCP client fails a whole lease later, long after you have stopped looking.&lt;/p&gt;&#10;&lt;p&gt;Search your playbooks and scripts for &lt;code&gt;systemd-run&lt;/code&gt; calls that run &lt;code&gt;ifup&lt;/code&gt;, &lt;code&gt;dhclient&lt;/code&gt; or anything else that forks into the background. Then check which cgroup that process ends up in.&lt;/p&gt;&#10;</description></item><item><title>fail2ban that never banned anyone: '/bin/sh: 1: nft: not found'</title><link>https://eghuro.cz/posts/fail2ban-nft-not-found/</link><pubDate>Fri, 16 Oct 2026 15:33:00 +0200</pubDate><guid>https://eghuro.cz/posts/fail2ban-nft-not-found/</guid><description>&lt;p&gt;fail2ban said the address was banned:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo fail2ban-client status nginx-limit-req&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; `- Banned IP list:&#9;203.0.113.10&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;From that address, right after:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ curl -s -o /dev/null -w &amp;#39;%{http_code}\n&amp;#39; https://example.com/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;302&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The reason was in &lt;code&gt;/var/log/fail2ban.log&lt;/code&gt; all along:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-03-14 10:21:05,205 fail2ban.utils [1293]: ERROR 7f5e8fbb0d30 -- exec: nft add table inet f2b-table&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-03-14 10:21:05,206 fail2ban.utils [1293]: ERROR 7f5e8fbb0d30 -- stderr: &amp;#39;/bin/sh: 1: nft: not found&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Debian, fail2ban bans through the &lt;code&gt;nft&lt;/code&gt; command. A host that runs firewalld can easily lack it. fail2ban then counts failures, lists bans, and blocks nothing, including the sshd jail.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, fail2ban 1.1.0-8, firewalld 2.3.1 with &lt;code&gt;FirewallBackend=nftables&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;nginx as a reverse proxy in front of a few web apps, SSH forwarded from the router&lt;/li&gt;&#10;&lt;li&gt;fail2ban set up by hand, with the stock sshd jail&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Debian&amp;rsquo;s &lt;code&gt;/etc/fail2ban/jail.d/defaults-debian.conf&lt;/code&gt; picks the ban action:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[DEFAULT]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;banaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;nftables&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;banaction_allports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;nftables[type=allports]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="how-it-showed-up"&gt;How it showed up &lt;/h2&gt;&#10;&lt;p&gt;I was adding jails for the web logins, so I tested them from a VPS outside every whitelisted network. Testing from home proves nothing when home is in &lt;code&gt;ignoreip&lt;/code&gt;. Twenty-five quick POSTs to a login endpoint:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; i in &lt;span class="k"&gt;$(&lt;/span&gt;seq 25&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code} &amp;#39;&lt;/span&gt; -d &lt;span class="nv"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;probe https://example.com/login&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first 11 got 400 from the app, the rest 429 from nginx&amp;rsquo;s rate limit, and the &lt;code&gt;nginx-limit-req&lt;/code&gt; jail banned the VPS as it should. Then the next request got a 302, as if nothing had happened.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;sudo nft list table inet f2b-table&lt;/code&gt; answered &lt;code&gt;sudo: nft: command not found&lt;/code&gt;. The log held ten lines like the ones above. Each failed ban left two ERROR lines in a log nobody reads, while &lt;code&gt;fail2ban-client status&lt;/code&gt; still listed the bans.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Install the &lt;code&gt;nft&lt;/code&gt; command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo apt install nftables&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemctl restart fail2ban&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Leave &lt;code&gt;nftables.service&lt;/code&gt; disabled, as the package installs it. Debian&amp;rsquo;s &lt;code&gt;/etc/nftables.conf&lt;/code&gt; starts with &lt;code&gt;flush ruleset&lt;/code&gt;, so enabling the service would wipe firewalld&amp;rsquo;s rules at the next boot. fail2ban doesn&amp;rsquo;t need the service, only the binary. It keeps its bans in a table of its own, &lt;code&gt;inet f2b-table&lt;/code&gt;, next to firewalld&amp;rsquo;s &lt;code&gt;inet firewalld&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;On restart, fail2ban restores the bans it has on record, so the ones that had silently failed now take effect:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fail2ban.actions [1649343]: NOTICE [nginx-limit-req] Restore Ban 203.0.113.10&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Give it a few seconds before you check. Right after the restart, &lt;code&gt;nft list table inet f2b-table&lt;/code&gt; still said &lt;code&gt;No such file or directory&lt;/code&gt;. A moment later, it showed the set and the rule:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table inet f2b-table {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;set addr-set-nginx-limit-req {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type ipv4_addr&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;elements = { 203.0.113.10 }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain f2b-chain {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter - 1; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;tcp dport { 80, 443 } ip saddr @addr-set-nginx-limit-req reject with icmp port-unreachable&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This time, curl on the VPS couldn&amp;rsquo;t connect at all (exit code 7). If you manage the host with Ansible or similar, add &lt;code&gt;nftables&lt;/code&gt; to the package list alongside &lt;code&gt;fail2ban&lt;/code&gt; so a rebuilt host doesn&amp;rsquo;t lose it again.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-binary-was-missing"&gt;Why the binary was missing &lt;/h2&gt;&#10;&lt;p&gt;firewalld talks to the kernel through the &lt;code&gt;python3-nftables&lt;/code&gt; bindings, not the &lt;code&gt;nft&lt;/code&gt; command, so it doesn&amp;rsquo;t pull in the &lt;code&gt;nftables&lt;/code&gt; package. fail2ban only recommends it as an alternative:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ apt-cache show fail2ban | grep ^Recommends&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Recommends: nftables | iptables, whois, python3-pyinotify, python3-setuptools&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;iptables&lt;/code&gt; was already installed (firewalld recommends it), so apt considered the recommendation met. The ban action still called &lt;code&gt;nft&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The same thing can happen with any ban action whose command isn&amp;rsquo;t on the host: the action fails, and the address stays on the ban list anyway.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Banned IP list&lt;/code&gt; shows what fail2ban decided, not what the firewall does. The only proof that a ban works is a blocked connection from the banned address, and &lt;code&gt;grep ERROR /var/log/fail2ban.log&lt;/code&gt; tells you why one isn&amp;rsquo;t.&lt;/p&gt;&#10;&lt;p&gt;Get one of your own hosts to ban a machine outside its whitelist, then try to connect from that machine.&lt;/p&gt;&#10;</description></item><item><title>'Downgrading is unsupported' after a config restore: the update guard was right</title><link>https://eghuro.cz/posts/update-guard-refusal-after-config-restore-was-correct/</link><pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/update-guard-refusal-after-config-restore-was-correct/</guid><description>&lt;p&gt;After a configuration restore, an updater refused with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;that release predates the gateway runtime these session stores now use. Downgrading is unsupported.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The operator requested an update. The restored configuration selected an older code line than the databases required. Restoring the intended per-install channel corrected target selection; bypassing the guard would have put incompatible code over newer state.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-newer-release-number-can-select-older-capabilities"&gt;A newer release number can select older capabilities &lt;/h2&gt;&#10;&lt;p&gt;The checkout followed a development branch. Its session databases already contained runtime-ledger history. The restored configuration backup lacked the install&amp;rsquo;s channel record, so release selection fell back to the stable channel.&lt;/p&gt;&#10;&lt;p&gt;The stable release had a higher patch version, but it came from a release branch without those runtime-ledger tables. Version ordering did not describe the relevant capability ordering.&lt;/p&gt;&#10;&lt;p&gt;The historical guard checked two things: whether the proposed target descended from the running commit, and whether a non-descendant target contained the runtime schema marker. It also read the session stores for ledger rows. Its refusal named the affected stores and stopped before moving the checkout.&lt;/p&gt;&#10;&lt;p&gt;The implementation explained the consequence: older code could not delete or prune sessions with that runtime history. This was a specific compatibility check, not proof that every forward commit or every future schema change would be safe.&lt;/p&gt;&#10;&#10;&lt;h2 id="repair-target-selection-before-changing-code"&gt;Repair target selection before changing code &lt;/h2&gt;&#10;&lt;p&gt;The diagnosis followed the configuration lookup:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Identify the actual install path and the channel record associated with it.&lt;/li&gt;&#10;&lt;li&gt;Compare that record with the backup that was restored. Check defaults when the record is absent.&lt;/li&gt;&#10;&lt;li&gt;Resolve the updater&amp;rsquo;s proposed target to a commit, rather than relying on its release label.&lt;/li&gt;&#10;&lt;li&gt;Compare the target&amp;rsquo;s ancestry and required state capabilities with the running checkout.&lt;/li&gt;&#10;&lt;li&gt;Reassert the intended channel through the updater&amp;rsquo;s supported configuration command, then run its check-only mode.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The corrected check selected the development branch and reported available forward commits—that verified channel selection. The update itself was deliberately not run, so the record does not establish a completed runtime upgrade.&lt;/p&gt;&#10;&lt;p&gt;For a Git-based installation, this read-only check is useful once &lt;code&gt;TARGET&lt;/code&gt; is set to the exact resolved commit. Run it inside that installation&amp;rsquo;s checkout:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git merge-base --is-ancestor HEAD &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;:?Set TARGET to the resolved update commit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exit status 0 means the target descends from the current checkout. Status 1 means it does not; other failures require investigation. A non-descendant can be a valid release-branch target, so this command alone is not a compatibility verdict. Check the particular schema capability named by the refusal.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-config-only-restore-can-create-a-mixed-generation"&gt;A config-only restore can create a mixed generation &lt;/h2&gt;&#10;&lt;p&gt;The database kept its newer state while the restored configuration lost an additive setting. In this implementation, channel records were keyed by install path identity. Restoring an older backup, or moving an install without recreating its record, could therefore change how later updates were selected.&lt;/p&gt;&#10;&lt;p&gt;Deployment automation should assert the intended channel after install or restore, then read it back through the supported interface. Do not hard-code a path-derived identifier from another machine. Following a development branch is appropriate only when that is the deliberate operating policy; it is not a universal fix for update errors.&lt;/p&gt;&#10;&lt;p&gt;For an intentional rollback, follow the application&amp;rsquo;s documented procedure for compatible code and pre-upgrade data together. Preserve the current state first, and account for work absent from the older backup. Editing a schema-version value or removing ledger rows merely to silence the guard would discard the evidence it uses to protect the store.&lt;/p&gt;&#10;&lt;p&gt;Add an update-target check to your restore drill before allowing the restored installation to update itself.&lt;/p&gt;&#10;</description></item><item><title>Rootless Podman backup without app.ini, and a restore that fails with 'Cannot change ownership to uid 100999'</title><link>https://eghuro.cz/posts/rootless-podman-backup-missing-app-ini/</link><pubDate>Thu, 15 Oct 2026 17:23:00 +0200</pubDate><guid>https://eghuro.cz/posts/rootless-podman-backup-missing-app-ini/</guid><description>&lt;p&gt;Restoring a Gitea backup to a new host failed on every directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar: data/gitea: Cannot change ownership to uid 100999, gid 100999: Invalid argument&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar: data/gitea/queues: Cannot change ownership to uid 100999, gid 100999: Invalid argument&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one is easy to fix. The worse problem made no noise at all, and I found it just before: the nightly backup didn&amp;rsquo;t contain &lt;code&gt;app.ini&lt;/code&gt;. If you back up a rootless Podman volume with a plain &lt;code&gt;tar&lt;/code&gt; as the host user, yours may be missing files too.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;Gitea 1.27 and PostgreSQL ran in rootless Podman on a VPS, with Gitea&amp;rsquo;s data in a bind mount under &lt;code&gt;~/gitea/data&lt;/code&gt;. The nightly backup ran as the user who owns the containers:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; gitea_db_1 pg_dump -U gitea gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-db-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.sql.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar c -C ~/gitea/data gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-data-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.tar.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The target was Gitea in Docker, inside an unprivileged LXC container on Debian.&lt;/p&gt;&#10;&lt;p&gt;The archive looked fine. It unpacked, and it contained all the repositories.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-the-archive-with-the-live-tree"&gt;Compare the archive with the live tree &lt;/h2&gt;&#10;&lt;p&gt;Before restoring, I compared the archive&amp;rsquo;s file list with the live data. List the live side from inside the user namespace, so that nothing is hidden from you:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh vps.example.com &lt;span class="s1"&gt;&amp;#39;cd ~/gitea/data &amp;amp;&amp;amp; podman unshare find gitea -type f&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &amp;gt; live.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar tjf gitea-data.tar.bz2 &lt;span class="p"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;&amp;#39;/$&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &amp;gt; backup.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;comm -23 live.txt backup.txt &amp;gt; missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wc -l live.txt backup.txt missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12359 live.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12342 backup.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 17 missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Among the 17 were the files Gitea can least do without:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/conf/app.ini&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/jwt/private.pem&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/git/.ssh/authorized_keys&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/avatars/... (3 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/indexers/... (4 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/sessions/... (2 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;app.ini&lt;/code&gt; holds Gitea&amp;rsquo;s &lt;code&gt;SECRET_KEY&lt;/code&gt;, its internal tokens, and the mail settings. &lt;code&gt;private.pem&lt;/code&gt; signs its OAuth2 tokens. A restore from this backup would have started with fresh secrets: anything encrypted with the old &lt;code&gt;SECRET_KEY&lt;/code&gt;, such as two-factor secrets, would no longer decrypt, and the mailer would be unconfigured.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-tar-skipped-them"&gt;Why tar skipped them &lt;/h2&gt;&#10;&lt;p&gt;Rootless Podman maps the container&amp;rsquo;s UIDs onto the subordinate range in &lt;code&gt;/etc/subuid&lt;/code&gt;. With the usual entry:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;deploy:100000:65536&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;container root is you, and container UID 1000 (Gitea&amp;rsquo;s &lt;code&gt;git&lt;/code&gt; user) is host UID 100000 + 999 = 100999. Files that Gitea creates with mode &lt;code&gt;0600&lt;/code&gt; belong to 100999, and you can&amp;rsquo;t read them from the host. The repository objects were &lt;code&gt;0644&lt;/code&gt; in &lt;code&gt;0755&lt;/code&gt; directories, so tar read those, and the archive looked complete.&lt;/p&gt;&#10;&lt;p&gt;tar does complain, on stderr, and exits with status 2. In &lt;code&gt;tar c ... | bzip2 &amp;gt; file&lt;/code&gt;, though, the pipeline&amp;rsquo;s exit status is bzip2&amp;rsquo;s, which is 0. Without &lt;code&gt;set -o pipefail&lt;/code&gt; and someone reading the job&amp;rsquo;s stderr, nothing tells you.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-the-backup"&gt;Fix the backup &lt;/h2&gt;&#10;&lt;p&gt;Run tar inside the user namespace, where you are root over every mapped UID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;podman unshare tar c -C ~/gitea/data gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-data-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.tar.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The archive then records the IDs as the container sees them (1000), not the host&amp;rsquo;s 100999.&lt;/p&gt;&#10;&lt;p&gt;To rescue the files without touching the live host, fetch only the missing ones the same way, then merge them into the old archive with bsdtar&amp;rsquo;s &lt;code&gt;@archive&lt;/code&gt; syntax:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh vps.example.com &lt;span class="s1"&gt;&amp;#39;cd ~/gitea/data &amp;amp;&amp;amp; podman unshare tar -c -T -&amp;#39;&lt;/span&gt; &amp;lt; missing.txt &amp;gt; missing.tar&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;bsdtar -czf gitea-complete.tar.gz @gitea-data.tar.bz2 @missing.tar&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="fix-the-restore"&gt;Fix the restore &lt;/h2&gt;&#10;&lt;p&gt;GNU tar run as root restores the owners recorded in the archive (&lt;code&gt;--same-owner&lt;/code&gt; is its default for root). Inside an unprivileged LXC container, root is itself a mapped user, and the container&amp;rsquo;s UIDs usually cover only 0 to 65535. UID 100999 doesn&amp;rsquo;t exist there, so &lt;code&gt;chown&lt;/code&gt; fails with &lt;code&gt;EINVAL&lt;/code&gt;, which tar reports as &lt;code&gt;Invalid argument&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t restore owners from an archive made somewhere else. Unpack with &lt;code&gt;--no-same-owner&lt;/code&gt; and set the owners the new Gitea expects:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar -C /var/tmp/gitea-restore --no-same-owner -xzf gitea-complete.tar.gz&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp -a /var/tmp/gitea-restore/gitea/. /srv/gitea/data/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R 1000:1000 /srv/gitea/data/git /srv/gitea/data/gitea&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R root:root /srv/gitea/data/ssh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This also covers the merged archive, which carries 100999 for some files and 1000 for others.&lt;/p&gt;&#10;&lt;p&gt;After the restore, check against the live instance: the repository count, &lt;code&gt;git fsck&lt;/code&gt; on every repository, and the SSH host key fingerprint on the Git SSH port, so existing clones still trust the new host. All three matched.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Under rootless Podman, a host-side &lt;code&gt;tar&lt;/code&gt; leaves out the files the container keeps private, which are often its secrets, and the pipeline still exits 0. Comparing the archive&amp;rsquo;s listing with &lt;code&gt;podman unshare find&lt;/code&gt; takes a minute and shows the gap.&lt;/p&gt;&#10;&lt;p&gt;Run the &lt;code&gt;podman unshare find&lt;/code&gt; and &lt;code&gt;comm&lt;/code&gt; check above against your own latest backup and see what comes back.&lt;/p&gt;&#10;</description></item><item><title>Decode a MikroTik autosupout.rif yourself: 20 lines of Python found the out-of-memory kernel panic</title><link>https://eghuro.cz/posts/mikrotik-decode-autosupout-rif/</link><pubDate>Wed, 14 Oct 2026 18:17:00 +0200</pubDate><guid>https://eghuro.cz/posts/mikrotik-decode-autosupout-rif/</guid><description>&lt;p&gt;A MikroTik router kept rebooting every 10 minutes to 2.6 hours, with almost no traffic. After each boot, the log said:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;system,error,critical router was rebooted without proper shutdown, probably kernel failure&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;system,error,critical kernel failure in previous boot&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;system,error,critical out of memory condition was detected&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;system,error,critical Automatic supout.rif file generated due to service malfunction, please contact MikroTik support and supply the generated file&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You don&amp;rsquo;t have to wait for support. &lt;code&gt;autosupout.rif&lt;/code&gt; is base64 with the bits in the other order, wrapped around zlib, and about 20 lines of Python turn it into plain text: the kernel&amp;rsquo;s last crash dumps, the log, &lt;code&gt;/proc&lt;/code&gt; and the full config. That&amp;rsquo;s where the cause was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Out of memory and no killable processes...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Kernel panic - not syncing: System is deadlocked on memory&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;MikroTik hAP ac² (IPQ4019, 128 MiB RAM, 16 MiB flash)&lt;/li&gt;&#10;&lt;li&gt;RouterOS 6.49.5, then 7.23.8 (long-term) with the legacy &lt;code&gt;wireless&lt;/code&gt; package; both crashed the same way&lt;/li&gt;&#10;&lt;li&gt;Python 3 on a Mac to decode the file&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="the-file-format"&gt;The file format &lt;/h2&gt;&#10;&lt;p&gt;Copy the file off the router and look at it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;scp admin@192.168.0.1:/autosupout.rif .&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;file autosupout.rif&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;head -2 autosupout.rif&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;autosupout.rif: ASCII text, with CRLF line terminators&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;--BEGIN ROUTEROS SUPOUT SECTION&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uQWZiV3ZAgHnt3X72tNOSb+7xXFYO5HTy0WSEgfKeG3rdsd643J2xjtT37s5kVHKSIbOWiUNJVs9&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It looks like base64, and &lt;code&gt;base64 -d&lt;/code&gt; turns it into noise. The alphabet is standard, but each group of four characters is read as a 24-bit little-endian number: the first character holds the lowest 6 bits, and the first output byte is the lowest 8 bits. Decoded, each section is its name, a NUL byte, and a zlib stream.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-decoder"&gt;The decoder &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;os&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;re&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;zlib&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ALPHABET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;[^A-Za-z0-9+/=]&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bytearray&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;=&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ALPHABET&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;j&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;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;little&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outdir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;--BEGIN ROUTEROS SUPOUT SECTION\r?\n(.*?)--END ROUTEROS SUPOUT SECTION&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;ascii&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;section&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zlib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decompressobj&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;[^\w.-]&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;_&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;replace&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;02d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;.txt&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;wb&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;02d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 supout.py autosupout.rif supout/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The 6.49.5 file had 76 sections and the 7.23.8 one 87. The useful ones:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Section&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;What&amp;rsquo;s in it&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;oops&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the kernel log of the last few crashes, each with its timestamp&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;log&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the RouterOS log since boot&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;resource&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;/system resource&lt;/code&gt; plus &lt;code&gt;/proc/meminfo&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;.proc&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;binary-ish dump of &lt;code&gt;/proc&lt;/code&gt;, including &lt;code&gt;slabinfo&lt;/code&gt;, &lt;code&gt;vmallocinfo&lt;/code&gt;, &lt;code&gt;buddyinfo&lt;/code&gt; (use &lt;code&gt;LC_ALL=C grep -a&lt;/code&gt;)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;export&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the full configuration&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;.debug&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;per-service debug output&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&#10;&lt;h2 id="what-it-showed"&gt;What it showed &lt;/h2&gt;&#10;&lt;p&gt;On 7.23.8, every crash in &lt;code&gt;oops&lt;/code&gt; ended the same way, after a process table in which every process had the same score:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ 691.378048][ T142] [ 150] 0 150 1667 153 28672 0 -1000 parser&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ 691.378492][ T142] Out of memory and no killable processes...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ 691.378509][ T142] Kernel panic - not syncing: System is deadlocked on memory&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;RouterOS runs every process with &lt;code&gt;oom_score_adj -1000&lt;/code&gt;, so the OOM killer has nothing to kill and the kernel panics. Any out-of-memory, however brief, is a reboot. 6.49.5 got there by another route, with the kernel set to panic on any OOM: &lt;code&gt;Kernel panic - not syncing: Out of memory: compulsory panic_on_oom is enabled&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The processes weren&amp;rsquo;t the problem, at about 0.5 MiB each. &lt;code&gt;resource&lt;/code&gt;, written a minute after boot, was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MemTotal: 118248 kB&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;MemFree: 7368 kB&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SUnreclaim: 52756 kB&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One minute into the boot, the kernel already held 52 MiB of unreclaimable memory. The caches in &lt;code&gt;slabinfo&lt;/code&gt; add up to only about 10 MiB, so the rest is allocated outside them. In two of the panics, other CPUs were inside the Wi-Fi driver (&lt;code&gt;ath_poll&lt;/code&gt;, &lt;code&gt;__w_qos_decrypt_mpdu&lt;/code&gt; in the &lt;code&gt;wlan&lt;/code&gt; and &lt;code&gt;ath&lt;/code&gt; modules), which makes it the main suspect, but turning one radio off didn&amp;rsquo;t settle it.&lt;/p&gt;&#10;&lt;p&gt;The same reading works on any embedded Linux box: check &lt;code&gt;oom_score_adj&lt;/code&gt; in the OOM dump, then &lt;code&gt;SUnreclaim&lt;/code&gt; against what the process table and &lt;code&gt;slabinfo&lt;/code&gt; explain.&lt;/p&gt;&#10;&#10;&lt;h2 id="read-the-timestamps-with-care"&gt;Read the timestamps with care &lt;/h2&gt;&#10;&lt;p&gt;The router has no battery-backed clock. After a crash, it restores an old time, and NTP takes minutes to catch up, so log and supout timestamps right after a reboot were about 7 minutes early. Five &amp;ldquo;crashes&amp;rdquo; stamped within two seconds of each other were most likely one boot loop.&lt;/p&gt;&#10;&lt;p&gt;The supout itself was written a minute &lt;em&gt;after&lt;/em&gt; the crash. The e-mail service tried to send the crash notice before PPPoE and DNS were up, died, and that service failure is what produced the file (&lt;code&gt;.debug&lt;/code&gt; shows &lt;code&gt;error: DNS resolve failed [code: 21]&lt;/code&gt; and then &lt;code&gt;died&lt;/code&gt;).&lt;/p&gt;&#10;&#10;&lt;h2 id="before-you-send-it-to-anyone"&gt;Before you send it to anyone &lt;/h2&gt;&#10;&lt;p&gt;The decoded sections held, in plain text, the PPPoE password, the WireGuard private key, a TSIG key, and a bearer token from a dynamic DNS script. Rotate those before you hand the file to MikroTik support or attach it to a forum post.&lt;/p&gt;&#10;&lt;p&gt;On a router this tight, delete the file after you copy it. The root of the file system is in RAM, and a 480 KiB supout is RAM the kernel needed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/file remove [find name~&amp;#34;autosupout&amp;#34;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A &lt;code&gt;supout.rif&lt;/code&gt; holds the kernel log, &lt;code&gt;/proc&lt;/code&gt; and your config, and you can read it in a minute. Next time your MikroTik writes one, decode it and read &lt;code&gt;oops&lt;/code&gt; before you open a support ticket.&lt;/p&gt;&#10;</description></item><item><title>No DHCP for an LXC container while iptables counts the packets as ACCEPT: firewalld rejects them in its own nftables table</title><link>https://eghuro.cz/posts/lxc-dhcp-iptables-accept-firewalld-reject/</link><pubDate>Tue, 13 Oct 2026 22:56:00 +0200</pubDate><guid>https://eghuro.cz/posts/lxc-dhcp-iptables-accept-firewalld-reject/</guid><description>&lt;p&gt;A new LXC container came up with an IPv6 address and no IPv4 address. According to &lt;code&gt;iptables&lt;/code&gt;, the host allowed DHCP from the container bridge, and the counter on that rule kept growing:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo iptables -L INPUT -v -n | grep dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0 0 ACCEPT tcp -- lxcbr0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 26 7956 ACCEPT udp -- lxcbr0 * 0.0.0.0/0 0.0.0.0/0 udp dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Twenty-six packets accepted, and dnsmasq, listening on that bridge, never logged a single DHCPDISCOVER.&lt;/p&gt;&#10;&lt;p&gt;With nftables, &lt;code&gt;accept&lt;/code&gt; only ends the chain it sits in. Any other base chain on the same hook still gets the packet. On this host, the next one belonged to firewalld, and firewalld rejected everything arriving on an interface that wasn&amp;rsquo;t in any of its zones.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13 host with the stock &lt;code&gt;lxc-net&lt;/code&gt; bridge: &lt;code&gt;lxcbr0&lt;/code&gt;, 10.0.3.0/24, dnsmasq at 10.0.3.1&lt;/li&gt;&#10;&lt;li&gt;firewalld 2.3.1 with &lt;code&gt;FirewallBackend=nftables&lt;/code&gt;, default zone &lt;code&gt;public&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;iptables 1.8.11, the nf_tables flavor Debian installs as &lt;code&gt;iptables&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;an unprivileged Debian 13 container running systemd-networkd with &lt;code&gt;DHCP=true&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;When lxc-net brings up the bridge, it adds its own iptables rules: ACCEPT for DNS and DHCP on &lt;code&gt;lxcbr0&lt;/code&gt;, ACCEPT for forwarding, and a MASQUERADE for the subnet. It knows nothing about firewalld.&lt;/p&gt;&#10;&#10;&lt;h2 id="following-the-packet"&gt;Following the packet &lt;/h2&gt;&#10;&lt;p&gt;The first visible symptom was the container&amp;rsquo;s bootstrap script, which spent nine minutes waiting on DNS lookups that timed out. From there, each check moved the problem one step along the path:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The container sent requests. With debug logging, systemd-networkd showed &lt;code&gt;DHCPv4 client: DISCOVER&lt;/code&gt; in the &lt;code&gt;selecting&lt;/code&gt; state.&lt;/li&gt;&#10;&lt;li&gt;The host received them. The ACCEPT counter went from 20 to 26 to 29 while I made the client retry with &lt;code&gt;networkctl reconfigure eth0&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;dnsmasq had the right socket open: &lt;code&gt;0.0.0.0%lxcbr0:67&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;dnsmasq received nothing. &lt;code&gt;strace&lt;/code&gt; showed it sitting in &lt;code&gt;poll()&lt;/code&gt;. During a retry, it read only an IPv6 router solicitation, which it answered. That is where the IPv6 address came from.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;AppArmor (no denials), reverse-path filtering (loose on the bridge), &lt;code&gt;br_netfilter&lt;/code&gt; (not loaded), leftover iptables-legacy tables (empty), and socket buffer drops (&lt;code&gt;RcvbufErrors&lt;/code&gt; 0) all came up clean. The host had no &lt;code&gt;tcpdump&lt;/code&gt;, and no &lt;code&gt;nft&lt;/code&gt; either:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo nft list ruleset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo: nft: command not found&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That left &lt;code&gt;iptables&lt;/code&gt; as the only view of the firewall, and &lt;code&gt;iptables&lt;/code&gt; shows only the tables it created. So the packet passed an ACCEPT at the input hook and never reached the socket. Listing the running services found a second firewall:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;firewalld.service loaded active running firewalld - dynamic firewall daemon&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo firewall-cmd --get-zone-of-interface=lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;no zone&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="two-tables-on-one-hook"&gt;Two tables on one hook &lt;/h2&gt;&#10;&lt;p&gt;After &lt;code&gt;apt install nftables&lt;/code&gt; (the binary is enough; leave &lt;code&gt;nftables.service&lt;/code&gt; disabled), you can see both chains on the input hook. Trimmed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo nft list chains&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table ip filter {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain INPUT {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table inet firewalld {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain filter_INPUT {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter + 10; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ip filter&lt;/code&gt; holds lxc-net&amp;rsquo;s rules, written through iptables-nft. &lt;code&gt;inet firewalld&lt;/code&gt; runs right after it, and an interface that no zone claims falls through to the default zone:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chain filter_INPUT_POLICIES {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;jump filter_IN_policy_allow-host-ipv6&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;jump filter_IN_public&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;reject with icmpx admin-prohibited&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;public&lt;/code&gt; allowed SSH and the DHCPv6 client, but no DHCP server and no DNS, so the DISCOVER hit the final &lt;code&gt;reject&lt;/code&gt;. The zone chain also ends with &lt;code&gt;meta l4proto { icmp, ipv6-icmp } accept&lt;/code&gt;, which is why the router solicitation got through.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Put the bridge in a firewalld zone. &lt;code&gt;trusted&lt;/code&gt; accepts everything, including forwarded traffic, which the containers need for internet access:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --zone&lt;span class="o"&gt;=&lt;/span&gt;trusted --add-interface&lt;span class="o"&gt;=&lt;/span&gt;lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --permanent --zone&lt;span class="o"&gt;=&lt;/span&gt;trusted --add-interface&lt;span class="o"&gt;=&lt;/span&gt;lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or with Ansible:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Trust the container bridge in firewalld&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.posix.firewalld&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;trusted&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;lxcbr0&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The container got its lease on the client&amp;rsquo;s next retry, without restarting anything. &lt;code&gt;trusted&lt;/code&gt; also lets containers reach every port on the host. If that&amp;rsquo;s too much, give the bridge a zone of its own that allows only DHCP, DNS, and forwarding. The same applies to any interface that another tool creates, such as a VPN tunnel.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-counter-lied"&gt;Why the counter lied &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains#Base_chain_priority"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;nftables wiki&lt;/a&gt;&#10; says that &amp;ldquo;an accept verdict &amp;hellip; isn&amp;rsquo;t necessarily final&amp;rdquo;, while &amp;ldquo;drops take immediate effect, with no further rules or chains being evaluated.&amp;rdquo; Base chains on one hook run in priority order. An accept ends one chain, and a drop or reject in any chain wins. When two firewalls share a host, the stricter one decides, and the permissive one keeps counting packets that never arrive.&lt;/p&gt;&#10;&lt;p&gt;Most of the time you won&amp;rsquo;t see the second table. &lt;code&gt;iptables -L&lt;/code&gt; lists only what iptables-nft created, and a host running firewalld may not have the &lt;code&gt;nft&lt;/code&gt; command at all. (That missing binary &lt;a&#10; href="https://eghuro.cz/posts/fail2ban-nft-not-found"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;also leaves fail2ban unable to ban anyone&lt;/a&gt;&#10;.)&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A counter on an ACCEPT rule only proves that the packet reached that rule. When an accepted packet still doesn&amp;rsquo;t arrive, list every base chain on the hook and find the later one that drops it.&lt;/p&gt;&#10;&lt;p&gt;If your host runs firewalld alongside anything that writes its own iptables rules, run &lt;code&gt;sudo nft list chains | grep hook&lt;/code&gt; and count the chains on &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;forward&lt;/code&gt;.&lt;/p&gt;&#10;</description></item><item><title>"Ansible requires blocking IO on stdin/stdout/stderr" when an AI agent runs Ansible</title><link>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</link><pubDate>Tue, 13 Oct 2026 22:34:00 +0200</pubDate><guid>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</guid><description>&lt;p&gt;The first time my coding agent ran Ansible, every command failed before Ansible had read a single file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR: Ansible requires blocking IO on stdin/stdout/stderr. Non-blocking file handles detected: &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--syntax-check&lt;/code&gt; too, and even &lt;code&gt;ansible --version&lt;/code&gt;. If you see this from an agent, a CI job, or a wrapper script, Ansible isn&amp;rsquo;t broken. The process that started it handed it non-blocking file handles, and a few shell redirections replace them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;ansible-core 2.20 (Ansible 13.3.0 from Homebrew) on macOS&lt;/li&gt;&#10;&lt;li&gt;A coding agent (Claude Code in VS Code) that runs commands in its own shell and captures their output&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The check has been in every Ansible CLI entry point since ansible-core 2.14 (2022).&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;O_NONBLOCK is a flag on the open file, not on the process, so every child that inherits the file descriptor inherits the flag too. The agent&amp;rsquo;s harness connects the shell&amp;rsquo;s stdout and stderr to non-blocking pipes, and the shell passes them on unchanged to &lt;code&gt;ansible-playbook&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;At startup, Ansible calls &lt;code&gt;os.get_blocking()&lt;/code&gt; on each of the three handles and exits if any of them is non-blocking. The list at the end of the message names the handles that failed. That makes it a good diagnostic: the first failure in my session listed only &lt;code&gt;&amp;lt;stderr&amp;gt;&lt;/code&gt;, because the command was &lt;code&gt;ansible --version | head -3&lt;/code&gt;. The pipe to &lt;code&gt;head&lt;/code&gt; had already given stdout a fresh, blocking handle, while stderr still pointed at the agent&amp;rsquo;s pipe.&lt;/p&gt;&#10;&lt;p&gt;Run this in the shell you suspect to see which handles are affected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -c &lt;span class="s1"&gt;&amp;#39;import os; print([os.get_blocking(fd) for fd in (0, 1, 2)])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;False&lt;/code&gt; in any position means Ansible will refuse to start there.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give Ansible three handles the shell creates itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;| cat&lt;/code&gt; gives stdout a new pipe, which is blocking.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; sends stderr into that same pipe. &lt;code&gt;| cat&lt;/code&gt; alone fixes only stdout.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;&amp;lt;/dev/null&lt;/code&gt; replaces stdin. My agent&amp;rsquo;s stdin was never flagged, but Ansible checks it too, and an agent can&amp;rsquo;t answer a prompt anyway.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The pipe has a side effect: the command&amp;rsquo;s exit status is now &lt;code&gt;cat&lt;/code&gt;&amp;rsquo;s, which is 0. A failed playbook looks like a success unless you turn on &lt;code&gt;pipefail&lt;/code&gt; (bash and zsh both have it):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To check the fix without touching your real shell, you can make all three handles non-blocking for a single command and try it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;NB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;import os,sys; [os.set_blocking(fd, False) for fd in (0,1,2)]; os.execvp(sys.argv[1], sys.argv[1:])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; ansible --version&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ERROR: ... Non-blocking file handles detected: &amp;lt;stdin&amp;gt;, &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; sh -c &lt;span class="s1"&gt;&amp;#39;ansible --version &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ansible [core 2.20.1] ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The outer &lt;code&gt;| cat&lt;/code&gt; and &lt;code&gt;&amp;lt;/dev/zero&lt;/code&gt; keep the flag on throwaway handles, so your terminal stays blocking.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-ansible-insists"&gt;Why Ansible insists &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/ansible/ansible/pull/77639"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;pull request that added the check&lt;/a&gt;&#10; explains it. On a non-blocking stdout, a large write can fail partway with &lt;code&gt;[Errno 11] write could not complete without blocking&lt;/code&gt;, and Ansible&amp;rsquo;s output gets cut off, or the run dies with an &amp;ldquo;Unexpected Exception&amp;rdquo;. The check turns those intermittent failures into one clear error at startup. The PR names another way to end up there: &lt;code&gt;ssh&lt;/code&gt; switches its handles to non-blocking while it runs and doesn&amp;rsquo;t always switch them back when it exits.&lt;/p&gt;&#10;&lt;p&gt;So the same error can turn up wherever a parent process hands down non-blocking pipes, CI runners included, or in a terminal after an &lt;code&gt;ssh&lt;/code&gt; that didn&amp;rsquo;t clean up.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-agent-remember-it"&gt;Make the agent remember it &lt;/h2&gt;&#10;&lt;p&gt;The agent worked this out within two minutes, and later that night it wrote the fix into the project&amp;rsquo;s skill for provisioning VMs:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Run Ansible from Claude Code with &lt;span class="sb"&gt;`&amp;lt;/dev/null`&lt;/span&gt; and pipe its output through &lt;span class="sb"&gt;`cat`&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The next morning, a session about a different service ran &lt;code&gt;ansible-playbook --syntax-check&lt;/code&gt; without the redirections and hit the same error. It never loaded the VM skill, so it never saw the line. A rule about how to run a tool belongs in the file the agent reads in every session, not in the instructions for one task.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&amp;ldquo;Ansible requires blocking IO&amp;rdquo; means the process that started Ansible handed it non-blocking file handles. &lt;code&gt;&amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&lt;/code&gt; gives it blocking ones, and &lt;code&gt;set -o pipefail&lt;/code&gt; keeps a failed run from looking like a success.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs Ansible, put that line in the instructions file it reads every session (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;).&lt;/p&gt;&#10;</description></item><item><title>Snapshot precision 0.99 let a wrong text line pass all seven screen tests</title><link>https://eghuro.cz/posts/snapshot-precision-099-hides-the-field-under-test/</link><pubDate>Tue, 13 Oct 2026 17:04:00 +0200</pubDate><guid>https://eghuro.cz/posts/snapshot-precision-099-hides-the-field-under-test/</guid><description>&lt;p&gt;A code review claimed that a list screen&amp;rsquo;s snapshot tests couldn&amp;rsquo;t fail on the very line they were written to pin: a small gray &amp;ldquo;Location&amp;rdquo; line under each search result. To check, I broke that line on purpose. The rows got the container&amp;rsquo;s short name instead of its full name, so every location line rendered the wrong text. Then I ran the suite:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenDeletedItemsShowTheirOrigin() passed after 0.296 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSearchResultsShowTheirLocation() passed after 0.708 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test run with 7 tests in 1 suite passed after 0.793 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;All seven passed against the broken code. If your snapshot tests record a whole screen at &lt;code&gt;.device&lt;/code&gt; layout with a &lt;code&gt;precision&lt;/code&gt; below 1.0, they may be just as blind.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;The project used &lt;a&#10; href="https://github.com/pointfreeco/swift-snapshot-testing"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;swift-snapshot-testing&lt;/a&gt;&#10; 1.19.2, with Swift Testing on the iOS simulator. A shared defaults file sets both tolerances:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The two gates do different jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;perceptualPrecision&lt;/code&gt; decides whether a single pixel counts as different. 0.90 absorbs anti-aliasing drift between developer Macs.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;precision&lt;/code&gt; decides what fraction of pixels must match. 0.99 lets 1% of the image differ by any amount.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The 0.99 was there to unblock CI: one render drifted by about 3,600 pixels between machines. The comment next to it even warned that it was &amp;ldquo;wide enough to hide a small real regression&amp;rdquo;. Every screen test used it anyway.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-arithmetic"&gt;The arithmetic &lt;/h2&gt;&#10;&lt;p&gt;An iPhone 17 Pro device frame is 1179×2556 pixels.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1179 × 2556 = 3,013,524 px&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1% of that = 30,135 px may differ freely&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wrong location = ~900 px actually differed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A 1% budget sounds strict. On a full screen it&amp;rsquo;s about a 58-point square of arbitrary change, more than all the location lines on the screen combined. The test was recorded correctly and passed honestly. It just had no way to fail on the thing in its name.&lt;/p&gt;&#10;&#10;&lt;h2 id="measuring-the-mutation"&gt;Measuring the mutation &lt;/h2&gt;&#10;&lt;p&gt;To size the gate, record the mutated renders, diff them against the committed references, then restore the references. A few lines of Pillow give a rough count (a pixel counts if any channel moves by more than 25):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;sys&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RGB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RGB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&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;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c2&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;c1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;.2%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;TEST_RUNNER_SNAPSHOT_TESTING_RECORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all xcodebuild &lt;span class="nb"&gt;test&lt;/span&gt; ... -only-testing:MyTests/ListScreenSnapshotTests&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show HEAD:path/to/reference.png &amp;gt; /tmp/head.png&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 pxdiff.py /tmp/head.png path/to/reference.png&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git checkout -- path/to/__Snapshots__ &lt;span class="c1"&gt;# put the real references back&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...DarkAppearance...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...SearchResultsAtTheDynamicTypeCap...: 0.14% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...SearchResultsShowTheirLocation...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...DeletedItemsShowTheirOrigin...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The smallest real defect was 0.03%, about 900 pixels, so the budget had to sit well below that. 0.9999 allows 301.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Give the screen suite its own precision and write the numbers down where the next person will see them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SnapshotDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// A device frame is 1179×2556 px, so the default 1% budget is 30,135 px: wider than every&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// Location line on the screen together. The wrong name moves about 900 px; this leaves a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// 301 px budget, while `perceptualPrecision` still absorbs anti-aliasing drift per pixel.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9999&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then rerun with the mutation still in place. It has to go red:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenDeletedItemsShowTheirOrigin() failed after 0.594 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSortMenuOpen() passed after 0.751 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenBrowsingTheRoot() passed after 0.807 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenSearchResultsAtTheDynamicTypeCap() failed after 1.220 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenSearchResultsShowTheirLocation() failed after 1.618 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSearchWithNoResults() passed after 1.659 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenDarkAppearance() failed after 2.036 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The four cases that draw location or origin lines failed. The three cases without them passed, which is also correct. After reverting the mutation, all seven passed again at 0.9999.&lt;/p&gt;&#10;&lt;p&gt;A 301-pixel budget is tight, and I verified it on one machine. If cross-machine drift trips it, look at the drift before you loosen the number.&lt;/p&gt;&#10;&#10;&lt;h2 id="shrink-the-frame-when-you-can"&gt;Shrink the frame when you can &lt;/h2&gt;&#10;&lt;p&gt;For a single component, a tight frame beats a tight precision. The same project measured a toolbar button&amp;rsquo;s enabled-to-disabled color change at 1,808 pixels, or 0.06% of a device frame. Rendered at 140×60 with &lt;code&gt;layout: .fixed(width:height:)&lt;/code&gt;, those 1,808 pixels became 2.4% of the image, well past a 1% budget. Keep &lt;code&gt;.device&lt;/code&gt; for tests that are really about the whole screen, and size their precision from the smallest change they claim to catch.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;precision&lt;/code&gt; is a fraction of the image, so a fixed value forgives more pixels as the frame grows. Before you trust a green snapshot, break the thing it&amp;rsquo;s named after, count the changed pixels, and compare that with &lt;code&gt;(1 - precision) × width × height&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Pick one snapshot test in your suite, break its subject on purpose, and see whether it goes red.&lt;/p&gt;&#10;</description></item><item><title>Cyber classifiers can interrupt offline pentest summaries</title><link>https://eghuro.cz/posts/agent-security-transcripts-need-provenance-and-checkpoints/</link><pubDate>Fri, 09 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/agent-security-transcripts-need-provenance-and-checkpoints/</guid><description>&lt;p&gt;An agent spent three minutes reading local transcripts from a finished, authorized security exercise, then stopped with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;This request triggered cyber-related safeguards.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No live target was involved. The job was offline summarization, yet the classifier interrupted it before it could write the requested report. The practical fix was to treat security-history analysis as a resumable data pipeline: retain message provenance, checkpoint extracted evidence, and keep the handoff usable by another approved model or provider.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-flag-shaped-string-is-not-a-captured-flag"&gt;A flag-shaped string is not a captured flag &lt;/h2&gt;&#10;&lt;p&gt;The first problem appeared before the classifier fired. A search through about 25 request dumps found dozens of flag-shaped strings. About 20 were spelling, capitalization, spacing, or hyphenation variants of one value.&lt;/p&gt;&#10;&lt;p&gt;Most variants appeared only in assistant reasoning and progress notes. The model repeatedly retyped a remembered value and changed it. A checkpoint then copied one mutation into later sessions.&lt;/p&gt;&#10;&lt;p&gt;Only strings preserved in tool results represented observed server output. To separate evidence from generation, use this origin-based distinction:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;user request instruction or supplied context&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;assistant text generated claim; unverified&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;assistant reasoning generated hypothesis; unverified&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tool call attempted action&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tool result observed output from that action&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;provider error evidence about the run, not the target&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This applies beyond CTF flags. Treat IDs, hashes, error codes, hostnames, and quoted responses in model prose as leads until the first tool result corroborates them. Exact-looking text is still generated text.&lt;/p&gt;&#10;&#10;&lt;h2 id="offline-processing-still-triggers-cyber-classifiers"&gt;Offline processing still triggers cyber classifiers &lt;/h2&gt;&#10;&lt;p&gt;The retained run used Opus 5.5. It parsed local request dumps, separated real and decoy-looking values, and extracted an existing SMB investigation summary. After that, the next model response was refused under the provider&amp;rsquo;s real-time cyber safeguard. The session recorded a &lt;code&gt;cyber&lt;/code&gt; refusal and pointed to its Cyber Verification Program. The output file did not exist afterward.&lt;/p&gt;&#10;&lt;p&gt;Some source dumps also contained earlier &lt;code&gt;cyber_policy&lt;/code&gt; failures from another provider along with a prompt to apply for Daybreak access. Because these records were historical input, they still increased the concentration of offensive-security language the summarizer saw.&lt;/p&gt;&#10;&lt;p&gt;A related authorized test had a similar shape: generate enough traffic against a non-production environment to trigger a WAF, then verify that the application displays and retries a CAPTCHA correctly. In a newer Opus session, the controlled load-test work was refused. The retained successful session used Opus 4.6 and built the test client, while a later session corrected its cookie handling and the request used to observe the challenge.&lt;/p&gt;&#10;&lt;p&gt;The awkward lesson is that task intent and classifier input are different things. In practice, &amp;ldquo;Summarize these old logs&amp;rdquo; can contain exploit commands, credentials, flags, and attack narratives. Likewise, &amp;ldquo;Test the CAPTCHA&amp;rdquo; can require traffic that resembles denial of service when reduced to request counts.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-audit-resumable-and-model-neutral"&gt;Make the audit resumable and model-neutral &lt;/h2&gt;&#10;&lt;p&gt;Do deterministic work before asking a model to synthesize:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Parse each transcript into typed events and preserve the source file, timestamp, role, tool-call ID, and tool-result ID.&lt;/li&gt;&#10;&lt;li&gt;Extract exact-match artifacts only from tool results. Store assistant claims separately as unverified leads.&lt;/li&gt;&#10;&lt;li&gt;Write incremental JSON or Markdown after each source file. Never leave the only useful state in the model&amp;rsquo;s context window.&lt;/li&gt;&#10;&lt;li&gt;Produce a sanitized handoff containing the authorization scope, completed files, pending questions, and evidence pointers.&lt;/li&gt;&#10;&lt;li&gt;Verify the final report exists and reconcile its artifact counts against the extraction index.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Provider access programs are worth the setup when this work recurs. In my experience, GPT Daybreak Blue justified the hassle for authorized security-related tasks. Kimi, GLM, and older model versions have also completed useful work when a newer classifier blocked the same class of jobs. That is a fallback, not a bypass: preserve the same authorization boundary, do not weaken the requested scope, and use models approved for the work.&lt;/p&gt;&#10;&lt;p&gt;Before trusting your next transcript summary, choose one exact token from the report and trace it back to the tool result that observed it.&lt;/p&gt;&#10;</description></item><item><title>Inherited agent notes can send the next agent down the same dead end</title><link>https://eghuro.cz/posts/distrust-inherited-agent-notes/</link><pubDate>Fri, 09 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/distrust-inherited-agent-notes/</guid><description>&lt;p&gt;A new agent can go through a failed investigation again if its first job is to absorb the conclusions of the previous agent. In the course of an authorised security exercise the user was repeatedly asking for a clean start as the prior progress files could be somewhat misleading. The useful reset was to establish a small verified baseline before using the inherited notes to select the next experiment.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-hint-survived-longer-than-its-evidence"&gt;A hint survived longer than its evidence &lt;/h2&gt;&#10;&lt;p&gt;The conversation history had instructions to go it alone after a number of failed attempts at the task. Another handoff stated the prior agent had got lost and that its notes could be misleading to the replacement.&lt;/p&gt;&#10;&lt;p&gt;One concrete example concerned an environment hint favoring IPv6. A later &lt;code&gt;SUMMARY.md&lt;/code&gt; excerpt said:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;IPv4 and IPv6 behave identically everywhere tested&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It was very explicit that the IPv6 hint in &lt;code&gt;AGENTS.md&lt;/code&gt; didn&amp;rsquo;t apply in that case. That&amp;rsquo;s a useful clarification, with a caveat: this is a recorded summary of the checks, not an independent re-run of every network probe. It does support the removal of that hint for that particular surface. It doesn&amp;rsquo;t in any way establish equivalence for all services or a successful resolution of the problem. The outcome is still unknown.&lt;/p&gt;&#10;&#10;&lt;h2 id="rebuild-only-enough-context-to-choose-a-test"&gt;Rebuild only enough context to choose a test &lt;/h2&gt;&#10;&lt;p&gt;On a restart read the current task and the mandatory operating instructions. Observe the authorization boundaries and safety rules. Be very clear in your mind about the difference between the instructions and the factual observations about the environment. A line in &lt;code&gt;AGENTS.md&lt;/code&gt; could be a binding rule, it could be a fact about the setup or it could be a hypothesis that you need to go out and check.&lt;/p&gt;&#10;&lt;p&gt;Then establish the facts required to inform the next decision. In a network investigation that might involve confirming the target endpoint, checking that it&amp;rsquo;s reachable and then making a baseline request to it. In a code issue scenario you&amp;rsquo;d want to reproduce the failure that was reported and then have a look at the current code before accepting any diagnosis from a previous developer.&lt;/p&gt;&#10;&lt;p&gt;Only then do you compare the inherited notes against that baseline. That way you don&amp;rsquo;t re-run the whole investigation and the new agent has its own baseline to work from.&lt;/p&gt;&#10;&lt;p&gt;A handoff instruction can be as short as:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Establish a minimal baseline from the current tool output.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Treat inherited diagnoses as hypotheses.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;For each claim you make, you need to be able to locate the evidence or at the very least be able to run a very cheap check.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Keep the required operating and safety instructions in force.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="preserve-the-test-behind-a-negative-result"&gt;Preserve the test behind a negative result &lt;/h2&gt;&#10;&lt;p&gt;A note such as &amp;ldquo;IPv6 works better&amp;rdquo; is completely devoid of the endpoint, the request and the comparison that would actually make it useful. &amp;ldquo;No difference on these tested endpoints&amp;rdquo; is much more specific and much easier to verify.&lt;/p&gt;&#10;&lt;p&gt;For future handoffs, record:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The claim and its scope (including when it was checked).&lt;/li&gt;&#10;&lt;li&gt;The command or test and a pointer to its output.&lt;/li&gt;&#10;&lt;li&gt;What remains is an interpretation or an unproven lead.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Keep the failed attempts too and state what each one ruled out. A failed request with one set of credentials doesn&amp;rsquo;t rule out the whole protocol. The next agent needs to be able to make the judgement as to whether repeating that test would in fact add any evidence.&lt;/p&gt;&#10;&lt;p&gt;Copying an assertion into successive summaries doesn&amp;rsquo;t constitute independent corroboration. If the raw output is missing then unverified is the correct designation rather than promoting it to a fact.&lt;/p&gt;&#10;&lt;p&gt;Prior to your next agent handoff, pick the assumption that will drive the next action and attach the relevant tool output in support of that assumption. If you can&amp;rsquo;t find it then that will be the restart task.&lt;/p&gt;&#10;</description></item><item><title>Apache UserDir /home/* can publish private dotfiles</title><link>https://eghuro.cz/posts/mod-userdir-whole-home-template-publishes-dotfiles/</link><pubDate>Wed, 07 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/mod-userdir-whole-home-template-publishes-dotfiles/</guid><description>&lt;p&gt;A personal-homepage feature can expose a user&amp;rsquo;s shell history even when directory listings are disabled. With Apache &lt;code&gt;mod_userdir&lt;/code&gt;, a whole-home mapping such as &lt;code&gt;UserDir /home/*&lt;/code&gt; maps a request for &lt;code&gt;/~demo/.bash_history&lt;/code&gt; directly into that home directory. Restrict publication to a dedicated subtree, and verify that a direct request for a private canary fails while the public page still loads.&lt;/p&gt;&#10;&lt;p&gt;This is a filesystem-publication error. It requires neither path traversal nor a dotfile-specific Apache vulnerability: the requested file is already inside the configured web namespace.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-the-mapping-exposes-private-files"&gt;How the mapping exposes private files &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://httpd.apache.org/docs/2.4/mod/mod_userdir.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apache UserDir documentation&lt;/a&gt;&#10; distinguishes relative directory names from absolute-path templates. Compare:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-apache" data-lang="apache"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Unsafe publication boundary if this tree is readable and authorized:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;UserDir&lt;/span&gt; &lt;span class="sx"&gt;/home/&lt;/span&gt;*&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Publish only the dedicated subtree:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;UserDir&lt;/span&gt; &lt;span class="sx"&gt;/home/&lt;/span&gt;*/public_html&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In the first form, &lt;code&gt;/~demo/.audit-canary&lt;/code&gt; maps to &lt;code&gt;/home/demo/.audit-canary&lt;/code&gt;. In the second, it maps to &lt;code&gt;/home/demo/public_html/.audit-canary&lt;/code&gt;. Filesystem permissions and Apache authorization still determine whether the request succeeds. The mapping alone does not grant read access.&lt;/p&gt;&#10;&lt;p&gt;A leading dot is a shell convention, not a general HTTP access-control rule. Likewise, &lt;code&gt;Options -Indexes&lt;/code&gt; prevents generated directory listings; it does not prevent fetching a known filename. A deployment may deny some dotfiles explicitly without covering every private file in a home directory.&lt;/p&gt;&#10;&#10;&lt;h2 id="exercise-the-exposure-without-collecting-secrets"&gt;Exercise the exposure without collecting secrets &lt;/h2&gt;&#10;&lt;p&gt;In an authorized test, have the administrator create a harmless &lt;code&gt;.audit-canary&lt;/code&gt; outside the public subtree and a public &lt;code&gt;index.html&lt;/code&gt; inside it. Make the canary readable by the test server so the check tests the mapping, not an accidental permission denial. Do not loosen permissions on real private files.&lt;/p&gt;&#10;&lt;p&gt;Against a disposable Apache instance bound to loopback on port 18080, request:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --fail --silent --show-error http://127.0.0.1:18080/~demo/.audit-canary&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --fail --silent --show-error http://127.0.0.1:18080/~demo/public_html/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The vulnerable fixture returned the private marker and the public marker, both with HTTP 200. That establishes unintended file disclosure without downloading anyone&amp;rsquo;s history or keys.&lt;/p&gt;&#10;&lt;p&gt;Real exploitation follows the same direct-file read. Shell history can contain passwords, tokens, or an &lt;code&gt;otpauth://&lt;/code&gt; provisioning URI. That URI contains the shared TOTP seed: anyone who obtains it can generate the same codes as the authenticator. If the same exposure also yields an unencrypted private key accepted by a service requiring key plus TOTP, both factors can be compromised. File disclosure alone does not prove that such credentials exist or that another service accepts them.&lt;/p&gt;&#10;&#10;&lt;h2 id="prevent-publication-at-the-directory-boundary"&gt;Prevent publication at the directory boundary &lt;/h2&gt;&#10;&lt;p&gt;Use a dedicated public directory with an explicit participant policy:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-apache" data-lang="apache"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;UserDir&lt;/span&gt; disabled&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;UserDir&lt;/span&gt; enabled demo&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;UserDir&lt;/span&gt; &lt;span class="sx"&gt;/home/&lt;/span&gt;*/public_html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Directory&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;/home/*/public_html&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;AllowOverride&lt;/span&gt; &lt;span class="k"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;Options&lt;/span&gt; -Indexes -FollowSymLinks&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;Require&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; granted&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/Directory&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This fragment assumes &lt;code&gt;mod_userdir&lt;/code&gt; is loaded. Adapt it to the site&amp;rsquo;s authorization policy, and check for overlapping directory sections. Remove broad grants on home directories, and keep private files unreadable to the web-server identity. If homepages are unnecessary, turn off the feature entirely. Symlinks and user-controlled overrides deserve separate review because they can expand the effective publication boundary.&lt;/p&gt;&#10;&lt;p&gt;If secrets were exposed, closing the route does not revoke copies already obtained. Replace affected keys and tokens, re-enroll affected TOTP credentials, and review access logs. Deleting a provisioning command from history cannot invalidate the seed.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-the-fix-and-distrust-status-code-folklore"&gt;Verify the fix and distrust status-code folklore &lt;/h2&gt;&#10;&lt;p&gt;I tested the mapping with Debian&amp;rsquo;s Apache 2.4.69 in a disposable local fixture containing only synthetic files. After adding &lt;code&gt;/public_html&lt;/code&gt; to the template, the same private-canary request returned 404, and &lt;code&gt;/~demo/&lt;/code&gt; returned the public marker with 200. No authentication service or real credential was involved.&lt;/p&gt;&#10;&lt;p&gt;The fixture&amp;rsquo;s nonexistent username also returned 404. Do not generalize a particular server&amp;rsquo;s 403/404 pattern into a reliable account-existence oracle. In &lt;a&#10; href="https://github.com/apache/httpd/blob/2.4.62/modules/mappers/mod_userdir.c"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apache 2.4.62&amp;rsquo;s mapper source&lt;/a&gt;&#10;, the absolute-template branch substitutes the supplied name without the home-directory lookup used by relative mappings. Later filesystem and authorization processing affects the response.&lt;/p&gt;&#10;&lt;p&gt;Before trusting a personal-homepage setup, put a nonsecret canary immediately outside its intended public directory and request it by its exact URL.&lt;/p&gt;&#10;</description></item><item><title>A format string in a TRACE logger can read the process environment</title><link>https://eghuro.cz/posts/format-string-trace-logger-reads-environment/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/format-string-trace-logger-reads-environment/</guid><description>&lt;p&gt;A TRACE handler that echoes a request with &lt;code&gt;snprintf(response, size, request_line)&lt;/code&gt; does more than log attacker-controlled text. It turns the request line into a &lt;code&gt;printf&lt;/code&gt; format string and can leak stack data in an authorized test, including pointers into &lt;code&gt;argv&lt;/code&gt; and &lt;code&gt;envp&lt;/code&gt;, through positional &lt;code&gt;%p&lt;/code&gt; and &lt;code&gt;%s&lt;/code&gt; conversions. The one-line fix is &lt;code&gt;snprintf(response, size, &amp;quot;%s&amp;quot;, request_line)&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;This is &lt;a&#10; href="https://cwe.mitre.org/data/definitions/134.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;CWE-134, use of an externally controlled format string&lt;/a&gt;&#10;. The bug often hides in diagnostics because developers treat the third argument as plain text. For the &lt;code&gt;printf&lt;/code&gt; family, it is the format string: a small program that says what to read and, with &lt;code&gt;%n&lt;/code&gt;, where to write.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-bounded-copy-does-not-make-the-format-safe"&gt;A bounded copy does not make the format safe &lt;/h2&gt;&#10;&lt;p&gt;The length argument to &lt;code&gt;snprintf&lt;/code&gt; bounds the output buffer. It does not stop format directives from being evaluated:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-c" data-lang="c"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cm"&gt;/* Vulnerable: request_line controls the format language. */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cm"&gt;/* Fixed: request_line is data for a constant format. */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;%s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The same rule applies to &lt;code&gt;printf(input)&lt;/code&gt;, &lt;code&gt;fprintf(log, input)&lt;/code&gt;, and wrappers around them. When no formatting is needed, prefer &lt;code&gt;fputs&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;A small local regression fixture makes the difference visible without reading sensitive memory. Save this as &lt;code&gt;format-log.c&lt;/code&gt; in a disposable directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-c" data-lang="c"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;string.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nf"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;%s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nf"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;TRACE /%p HTTP/1.1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probe&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nf"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;vulnerable=%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;fixed=%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;strcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;strcmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run it locally:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cc -O0 -Wall -Wextra format-log.c -o format-log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./format-log&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The vulnerable line expands &lt;code&gt;%p&lt;/code&gt;; the fixed line preserves it literally. The pointer value is intentionally unspecified.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-to-exercise-the-disclosure-safely"&gt;How to exercise the disclosure safely &lt;/h2&gt;&#10;&lt;p&gt;On an authorized disposable target, start with a normal request and a literal-percent control. Then use &lt;code&gt;%p&lt;/code&gt; or positional &lt;code&gt;%N$p&lt;/code&gt; to map candidate argument slots. Dereferencing a candidate with &lt;code&gt;%N$s&lt;/code&gt; asks the process to treat that value as a string pointer, so expect child crashes and test one slot per fresh connection. Never run a broad &lt;code&gt;%s&lt;/code&gt; sweep against production.&lt;/p&gt;&#10;&lt;p&gt;Environment strings commonly sit near the initial argument and environment vectors. A pointer leak can therefore become a secret leak when a service receives credentials through environment variables. Slot numbers are build- and runtime-specific; a number from one binary is not a portable exploit.&lt;/p&gt;&#10;&lt;p&gt;Fork-per-connection servers make repeated probing easier than expected. Children inherit the parent&amp;rsquo;s already-randomized mappings so that addresses can remain stable across child connections until the parent restarts. ASLR still applies to the parent; &lt;code&gt;fork()&lt;/code&gt; simply copies that layout.&lt;/p&gt;&#10;&lt;p&gt;Treat &lt;code&gt;%n&lt;/code&gt; as a separate, higher-risk write primitive. A read-only demonstration is enough to prove the logging flaw and should be the limit of most authorized validation.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-the-sink-and-reduce-the-impact"&gt;Fix the sink and reduce the impact &lt;/h2&gt;&#10;&lt;p&gt;Make every format string a constant and enable compiler diagnostics such as &lt;code&gt;-Wformat=2 -Wformat-security&lt;/code&gt;. Add a regression request containing &lt;code&gt;%p&lt;/code&gt;, &lt;code&gt;%s&lt;/code&gt;, and &lt;code&gt;%n&lt;/code&gt; as literal text, then assert that the logger preserves every byte. Static analysis should flag tainted data reaching any &lt;code&gt;printf&lt;/code&gt;-family format parameter.&lt;/p&gt;&#10;&lt;p&gt;Do not rely on input filtering. Blocking a few percent directives is easy to get wrong and leaves other format sinks exposed. Fix the call boundary.&lt;/p&gt;&#10;&lt;p&gt;Finally, keep long-lived credentials out of network-facing process environments where possible. Use narrowly scoped, short-lived credentials and isolate the component. That limits the damage from any memory disclosure, but it does not replace fixing the format string.&lt;/p&gt;&#10;&lt;p&gt;Search your diagnostic and TRACE paths for nonliteral &lt;code&gt;printf&lt;/code&gt; formats before assuming they are harmless logging code.&lt;/p&gt;&#10;</description></item><item><title>Next.js Server Action arguments are Flight data: use error digests carefully</title><link>https://eghuro.cz/posts/nextjs-server-action-args-are-flight-data-error-digest-oracle/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/nextjs-server-action-args-are-flight-data-error-digest-oracle/</guid><description>&lt;p&gt;A Next.js Server Action can receive a JSON-looking body whose strings are interpreted as React Flight tokens. If ordinary input works but &lt;code&gt;$&lt;/code&gt;-prefixed input changes the response or produces HTTP 500, inspect the decoded arguments before trying more SQL or shell payloads. Differences between error digests can help separate failure paths, but they cannot establish a vulnerable version or remote code execution on their own.&lt;/p&gt;&#10;&lt;p&gt;This matters when testing the request behind an action call. The normal client serializer escapes literal strings for the protocol; manually editing the wire body can change what the server decodes.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-application-sees-the-result-of-decodereply"&gt;The application sees the result of decodeReply &lt;/h2&gt;&#10;&lt;p&gt;In the tagged &lt;a&#10; href="https://github.com/vercel/next.js/blob/v15.0.0/packages/next/src/server/app-render/action-handler.ts"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Next.js 15.0.0 action handler&lt;/a&gt;&#10;, fetch-action bodies pass through &lt;code&gt;decodeReply&lt;/code&gt; or its streaming counterpart before the selected action runs. The &lt;a&#10; href="https://github.com/facebook/react/blob/v19.0.0/packages/react-server/src/ReactFlightReplyServer.js"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;React 19.0.0 reply decoder&lt;/a&gt;&#10; interprets &lt;code&gt;$&lt;/code&gt;-prefixed strings, including escaped literals, special numbers, and references.&lt;/p&gt;&#10;&lt;p&gt;For a local decoder, these argument values illustrate the distinction:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Wire string inside the arguments array&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Decoded argument&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ordinary&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;string &lt;code&gt;ordinary&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;$$literal&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;string &lt;code&gt;$literal&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;$Infinity&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;number &lt;code&gt;Infinity&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;$-Infinity&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;number &lt;code&gt;-Infinity&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;$undefined&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;undefined&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;A value changing type before the application function runs explains why an otherwise boring search action can behave strangely. Input validation inside that function does not protect an earlier, vulnerable deserializer.&lt;/p&gt;&#10;&#10;&lt;h2 id="reproduce-the-protocol-locally-before-probing-further"&gt;Reproduce the protocol locally before probing further &lt;/h2&gt;&#10;&lt;p&gt;Use an isolated scratch directory. The following intentionally installs an old, vulnerable package for an offline decoder experiment. Do not deploy it, expose a listener, or feed it untrusted traffic. Delete the directory afterward.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;LAB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;mktemp -d &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="p"&gt;:?Set TMPDIR to your scratch directory&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/flight-lab.XXXXXX&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;npm install --ignore-scripts --no-audit --no-fund --save-exact &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; react@19.0.0 react-dom@19.0.0 react-server-dom-webpack@19.0.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Save this as &lt;code&gt;check.cjs&lt;/code&gt; in that directory, then run &lt;code&gt;node --conditions=react-server check.cjs&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;node:assert/strict&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decodeReply&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;react-server-dom-webpack/server.node&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;ordinary&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;ordinary&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$$literal&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;$literal&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$Infinity&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$-Infinity&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$undefined&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;decodeReply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;assert&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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&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="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;5 Flight decoding cases passed&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;check&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="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This checks protocol behavior, not exploitability. For an authorized application assessment, reproduce the actual resolved packages and runtime conditions. Next.js may bundle its React implementation; a standalone npm package is not automatically an exact framework replica.&lt;/p&gt;&#10;&lt;p&gt;Preserve the decoded root&amp;rsquo;s expected shape, too. The tagged Next.js handler invokes &lt;code&gt;actionHandler.apply(null, boundActionArguments)&lt;/code&gt;. A root that resolves to a primitive string fails that call&amp;rsquo;s array-like argument requirement before the intended action behavior can be tested. Keep transport decoding and action invocation as separate checkpoints.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-an-error-digest-can-tell-you"&gt;What an error digest can tell you &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/vercel/next.js/blob/v15.0.0/packages/next/src/server/app-render/create-error-handler.tsx"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Next.js 15.0.0 error handler&lt;/a&gt;&#10; generates digests using &lt;code&gt;stringHash&lt;/code&gt; over error text and stack information in several paths, unless a digest already exists. One path uses:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stringHash&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="o"&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;stack&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Different parser branches can throw from different stack locations and yield different digests even when the visible error message is generic. Record the complete response, status, and digest for each controlled input; repeat baseline requests to check stability. Correlate differences with logs in the local replica.&lt;/p&gt;&#10;&lt;p&gt;A digest is a lossy fingerprint. Build paths, package versions, runtime stacks, and existing application digests can alter it. Distinct digests suggest distinct error material; matching digests do not prove identical execution paths. HTTP 500 alone does not even locate the failure in Flight decoding.&lt;/p&gt;&#10;&#10;&lt;h2 id="separate-protocol-reachability-from-react2shell"&gt;Separate protocol reachability from React2Shell &lt;/h2&gt;&#10;&lt;p&gt;React&amp;rsquo;s &lt;a&#10; href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;CVE-2025-55182 advisory&lt;/a&gt;&#10; confirms unauthenticated RCE through a flaw in decoding Server Function payloads. Ordinary Flight tokens also exist in correct protocol handling. Observing the transformations above therefore does not diagnose that CVE.&lt;/p&gt;&#10;&lt;p&gt;For exploit validation, first reproduce the affected decoder in a private replica and demonstrate a harmless, deterministic effect there. Keep that result separate from any authorized remote confirmation. The experiment above deliberately stops at benign decoding.&lt;/p&gt;&#10;&lt;p&gt;For prevention, follow the current React and framework advisories, update the resolved server-side packages, rebuild, and verify what the running artifact actually contains. Do not use the historical demonstration versions as a patch recommendation. Authenticate and authorize actions independently, validate decoded arguments, and avoid exposing decoded-input debug echoes. Those controls do not replace fixing a vulnerable decoder.&lt;/p&gt;&#10;&lt;p&gt;When a &lt;code&gt;$&lt;/code&gt; probe changes behavior, capture the full response and reproduce that transformation locally before assigning a CVE or an impact rating.&lt;/p&gt;&#10;</description></item><item><title>Prompt injection: extract tokens, then decode them offline</title><link>https://eghuro.cz/posts/prompt-injection-extract-tokens-decode-offline/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/prompt-injection-extract-tokens-decode-offline/</guid><description>&lt;p&gt;A model can repeat hidden, encoded prompt fragments and still produce a different decoded secret on each attempt. When an authorized prompt-injection test reaches that point, stop asking the model to solve its own leak. Capture the raw tokens with position and case intact, then decode them with deterministic code offline.&lt;/p&gt;&#10;&lt;p&gt;This separates two questions: can the model disclose protected context, and can a decoder reconstruct the encoded value? A fluent final answer is poor evidence for either one.&lt;/p&gt;&#10;&#10;&lt;h2 id="lexical-refusal-filters-leave-a-semantic-gap"&gt;Lexical refusal filters leave a semantic gap &lt;/h2&gt;&#10;&lt;p&gt;A filter that rejects phrases such as &amp;ldquo;show the system prompt&amp;rdquo; or &amp;ldquo;reveal the secret&amp;rdquo; checks wording, not intent. Semantically equivalent requests can ask the model to quote, classify, count, or list text from its instructions without using the blocked nouns. If the model can return those fragments, the control has failed even when the obvious request is refused.&lt;/p&gt;&#10;&lt;p&gt;For an authorized assessment, use a synthetic canary in a test system. Ask for a numbered transcription of the relevant tokens, one token per line, with original capitalization. Repeat individual positions and compare exact strings. Keep the transcript as evidence. Do not put a real credential in the prompt merely to test whether it leaks.&lt;/p&gt;&#10;&lt;p&gt;Do not ask the model to join, normalize, or decode the result. Those extra transformations invite changed case, missing groups, repaired spelling, and invented separators. Temperature zero may reduce variation, but it does not turn generation into a parser.&lt;/p&gt;&#10;&#10;&lt;h2 id="decode-a-synthetic-transcript-deterministically"&gt;Decode a synthetic transcript deterministically &lt;/h2&gt;&#10;&lt;p&gt;Suppose a test prompt encodes characters as phonetic words. Capitalized words mean uppercase letters, lowercase words mean lowercase letters, and number words mean digits. Save this offline decoder as &lt;code&gt;decode.py&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NUMBERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;zero&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;one&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;two&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;three&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;four&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;4&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;five&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;5&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;six&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;6&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;seven&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;7&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;eight&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nine&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;9&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NUMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NUMBERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isalpha&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;letter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;letter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;upper&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;token&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isupper&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;letter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;unsupported token: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Alpha&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bravo&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Charlie&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;delta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;seven&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AbCd7&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run &lt;code&gt;python3 decode.py&lt;/code&gt;; it prints &lt;code&gt;AbCd7&lt;/code&gt;. The assertion is a negative control against accidental lowercasing or regrouping. Adapt the decoder only from a documented encoding rule. If a separator or group length is unknown, report that ambiguity instead of asking the model to guess.&lt;/p&gt;&#10;&lt;p&gt;Keep an evidence table with the position, raw token, repeated observation, decoded character, and rule used. Hash or otherwise preserve the raw transcript if chain of custody matters. A model-generated summary is not a substitute for the original response.&lt;/p&gt;&#10;&#10;&lt;h2 id="prevent-prompt-disclosure-at-the-trust-boundary"&gt;Prevent prompt disclosure at the trust boundary &lt;/h2&gt;&#10;&lt;p&gt;Keyword filters are a weak secondary control. The primary fix is to keep secrets and privileged instructions out of model-visible context. Give tools short-lived, scoped credentials through an execution boundary that does not echo them into prompts or tool results. Treat retrieved documents, web pages, and user content as untrusted data, not instructions.&lt;/p&gt;&#10;&lt;p&gt;Constrain outputs structurally when the application expects a fixed schema, and validate them outside the model: test semantic paraphrases, indirect requests, translations, and token-by-token extraction against synthetic canaries. Log enough to identify which context source leaked without recording the secret itself.&lt;/p&gt;&#10;&lt;p&gt;A successful refusal to one phrase proves only that one phrase was blocked. Verify that the model cannot disclose the underlying text in pieces.&lt;/p&gt;&#10;</description></item><item><title>APFS sparse bundle on SMB: rsync "Operation not supported (45)" and F_FULLFSYNC</title><link>https://eghuro.cz/posts/apfs-sparse-bundle-smb-fullfsync/</link><pubDate>Tue, 29 Sep 2026 12:55:00 +0200</pubDate><guid>https://eghuro.cz/posts/apfs-sparse-bundle-smb-fullfsync/</guid><description>&lt;p&gt;A backup job that rsyncs into encrypted disk images on a NAS started printing this for every file (paths elided):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync: [generator] failed to set times on …&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync: [receiver] mkstemp …&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync: [generator] delete_file: unlink … failed: Operation not supported (45)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then &lt;code&gt;hdiutil attach&lt;/code&gt; for the next image hung for over ten minutes, and the two images whose rsyncs had finished stayed mounted.&lt;/p&gt;&#10;&lt;p&gt;If you keep APFS sparse bundles on an SMB share and see error 45 inside them, the files aren&amp;rsquo;t the problem. The share can&amp;rsquo;t do &lt;code&gt;F_FULLFSYNC&lt;/code&gt;, and APFS can&amp;rsquo;t commit a single transaction without it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;macOS 27.0.1 dropped AFP, so an old NAS that speaks only SMB 2.1 got mounted over SMB instead. Each backup source has its own AES-256 sparse bundle on the share, created like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hdiutil create -encryption AES-256 -type SPARSEBUNDLE -size 50g &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -fs APFS -volname app -stdinpass /Volumes/backup/staging/app.sparsebundle&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The job attaches a bundle, rsyncs a server&amp;rsquo;s files into it, and detaches it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-kernel-log"&gt;The kernel log &lt;/h2&gt;&#10;&lt;p&gt;rsync names the files, so the obvious suspects are permissions or the files themselves. The kernel log points at the image instead (timestamps shortened, process column removed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 90m --style compact &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;kernel&amp;#34; AND eventMessage CONTAINS &amp;#34;apfs&amp;#34;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:43:53.531 disk7s1 mount-complete volume app, requested by: mount_apfs (pid 82535); parent: mount (pid 82534)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:43:53.532 disk7s1 Host is remote&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:47:45.393 (apfs) tx_flush_thread:1678: disk7 tx_flush(8311) failed, 45, # 297&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12:47:45.931 (apfs) tx_flush_thread:1678: disk7 tx_flush(8311) failed, 45, # 298&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;APFS noticed the image lives on a remote host. From 24 seconds after mounting, it failed to flush a transaction about twice a second, with error 45 (&lt;code&gt;ENOTSUP&lt;/code&gt;). The second mounted image did the same. Nothing written inside them was being committed.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-probe"&gt;The probe &lt;/h2&gt;&#10;&lt;p&gt;Error 45 from a flush suggested the sync call, not the write. This script tries plain &lt;code&gt;fsync&lt;/code&gt; and the two stronger syncs macOS offers. Run it from a directory on the share:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;os&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_synctest&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_RDWR&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_CREAT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mo"&gt;0o600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;x&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;op&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fsync&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;F_FULLFSYNC&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;F_BARRIERFSYNC&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&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;op&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&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="s2"&gt;&amp;#34;OK&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;OSError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&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="s2"&gt;&amp;#34;-&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fsync OK&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;F_FULLFSYNC -&amp;gt; [Errno 45] Operation not supported&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;F_BARRIERFSYNC -&amp;gt; [Errno 45] Operation not supported&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;APFS asks the disk image to flush; the disk image passes that on as &lt;code&gt;F_FULLFSYNC&lt;/code&gt; on its band files on the share, and the SMB client says no.&lt;/p&gt;&#10;&lt;p&gt;The NAS also had a share called &lt;code&gt;TimeMachineBackup&lt;/code&gt;, and Time Machine over SMB needs full sync, so it seemed worth a try. Same result. Its Time Machine backups dated from the AFP days.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-works"&gt;What works &lt;/h2&gt;&#10;&lt;p&gt;Keep the image off the share. The bundles moved to a USB drive, and every healthy one attaches, writes, deletes, and detaches fine there. That&amp;rsquo;s where the job runs now.&lt;/p&gt;&#10;&lt;p&gt;If the image has to stay on the share, use HFS+ inside it. A test bundle on the same share, created with &lt;code&gt;-fs HFS+J&lt;/code&gt; instead of &lt;code&gt;-fs APFS&lt;/code&gt;, passed everything the job does: rsync with &lt;code&gt;--delete-during&lt;/code&gt;, rename, setting times, deletes, detach and reattach.&lt;/p&gt;&#10;&lt;p&gt;If the server runs Samba and you control it, &lt;code&gt;fruit:time machine = yes&lt;/code&gt; on the share makes vfs_fruit advertise the FULLSYNC volume capability to macOS clients. That should let APFS images commit, but it wasn&amp;rsquo;t tested here.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-the-images-you-already-have"&gt;Check the images you already have &lt;/h2&gt;&#10;&lt;p&gt;The failed commits also left damage. When the bundles were copied to the USB drive, one of them wouldn&amp;rsquo;t mount:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hdiutil: attach failed - no mountable file systems&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A read-only check of its volume (&lt;code&gt;fsck_apfs -n&lt;/code&gt;) said why:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;warning: (oid 0x74e6) om: btn: invalid o_cksum (0xdfbd1ccee26c9006)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;** The volume /dev/rdisk8s1 with UUID … was found to be corrupt and cannot be repaired.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its band files had last been modified during the failed SMB run, and the original on the NAS was identical, so the copy wasn&amp;rsquo;t to blame. Since it was only a staging mirror, it got recreated empty, and the next run filled it again. After any such episode, attach each image and run &lt;code&gt;diskutil verifyVolume&lt;/code&gt; on it.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-restic-didnt-mind"&gt;Why restic didn&amp;rsquo;t mind &lt;/h2&gt;&#10;&lt;p&gt;Restic repositories on the same share were unaffected. Restic is written in Go, and Go&amp;rsquo;s &lt;code&gt;Fsync&lt;/code&gt; on macOS tries &lt;code&gt;F_FULLFSYNC&lt;/code&gt; and falls back to plain &lt;code&gt;fsync&lt;/code&gt; on &lt;code&gt;ENOTSUP&lt;/code&gt;. The &lt;a&#10; href="https://github.com/golang/go/blob/master/src/internal/poll/fd_fsync_darwin.go"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;comment in the Go source&lt;/a&gt;&#10; names this exact case: &amp;ldquo;There are scenarios such as SMB mounts where fcntl will fail with ENOTSUP.&amp;rdquo; APFS inside a disk image has no such fallback: when the full flush fails, it stops committing.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An APFS disk image on an SMB share works only if the share supports &lt;code&gt;F_FULLFSYNC&lt;/code&gt;. Without it, every write inside the image fails with error 45, and the failed commits can corrupt the image.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re moving sparse bundles from AFP to SMB, run the probe above on the new share before you mount anything on it.&lt;/p&gt;&#10;</description></item><item><title>macOS SMB: OSError: [Errno 9] Bad file descriptor after a silent reconnect</title><link>https://eghuro.cz/posts/macos-smb-ebadf-after-reconnect/</link><pubDate>Tue, 29 Sep 2026 12:15:00 +0200</pubDate><guid>https://eghuro.cz/posts/macos-smb-ebadf-after-reconnect/</guid><description>&lt;p&gt;A Python script that had encrypted backup archives on a NAS for months started failing on every run:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Traceback (most recent call last):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; File &amp;#34;/Volumes/backup/./encrypt-one.py&amp;#34;, line 74, in &amp;lt;module&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; main()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; File &amp;#34;/Volumes/backup/./encrypt-one.py&amp;#34;, line 38, in main&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; with open(f, &amp;#34;rb&amp;#34;) as src:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OSError: [Errno 9] Bad file descriptor&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then the bash script that called it stopped too. Not intermittently: start the job and something fails sooner or later.&lt;/p&gt;&#10;&lt;p&gt;If you run long jobs from an SMB share on macOS and get &lt;code&gt;EBADF&lt;/code&gt; on files that are plainly there, the cause is probably a session reconnect you never saw, and the kernel log is the only place that records it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;macOS 27 dropped AFP, so an old NAS that speaks only SMB 2.1 got mounted over SMB instead. A bash script on the share runs &lt;code&gt;encrypt-one.py&lt;/code&gt; on a few archives in parallel with &lt;code&gt;xargs -P&lt;/code&gt;. Each run streams a 600 MB archive from the share through &lt;code&gt;openssl enc&lt;/code&gt; and writes the result back to the same share.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-dead-end"&gt;The dead end &lt;/h2&gt;&#10;&lt;p&gt;The traceback points at &lt;code&gt;open()&lt;/code&gt;, so the first move was to open and read that archive by hand. It worked, and so did a read under &lt;code&gt;xargs -P 3&lt;/code&gt;. A file that opens fine and a job that fails anyway tells you the file isn&amp;rsquo;t the problem. Something happens to the handle while it&amp;rsquo;s open.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-kernel-log"&gt;The kernel log &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;smbutil&lt;/code&gt; gave the first hint:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ smbutil statshares -m /Volumes/backup&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SMB_VERSION SMB_2.1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SESSION_RECONNECT_TIME 2026-09-29 12:01:17&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SESSION_RECONNECT_COUNT 4&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Four reconnects; the last one was inside the failed run. The kernel log explains what each one does (timestamps shortened, process column removed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 1h --style compact &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;kernel&amp;#34; AND eventMessage CONTAINS[c] &amp;#34;smb&amp;#34;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:15.502 (smbfs) smb_iod_sendall: id 0 ts &amp;gt; iod_lastrecv &amp;amp;&amp;amp; ts &amp;gt; sr_timesent.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:15.502 (smbfs) smb_iod_reconnect: id 0 Starting reconnect with nas.local&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:15.502 (smbfs) smb2_smb_parse_change_notify: smb_rq_reply failed 60&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:16.523 (smbfs) smb_iod_reconnect: id 0 The negotiate succeeded to nas.local&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.065 (smbfs) smb2fs_reconnect_dur_handle: Missing durable handle on &amp;lt;archive.tar.bz2.enc.tmp&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.173 (smbfs) smb2fs_reconnect_dur_handle: Missing durable handle on &amp;lt;backup.sh&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.186 (smbfs) smb2fs_reconnect_dur_handle: Missing durable handle on &amp;lt;archive.tar.bz2&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.205 (smbfs) smb_iod_reconnect: id 0: Reconnect completed successfully.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.212 (smbfs) smb2_smb_read_write_async: smb_rq_reply failed 9&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.212 (smbfs) smbfs_do_strategy: archive.tar.bz2: READ failed with an error of 9&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;11:58:17.283 (smbfs) smbfs_do_strategy: archive.tar.bz2.enc.tmp: WRITE failed with an error of 9&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The NAS stopped answering under sustained I/O (error 60 is &lt;code&gt;ETIMEDOUT&lt;/code&gt;). macOS dropped the session and reconnected, and that part worked. Then it tried to reattach each open file; the server had nothing to reattach them to, and every read and write on those files failed with error 9, &lt;code&gt;EBADF&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Look at the middle line: &lt;code&gt;backup.sh&lt;/code&gt; is on the list. Bash reads a script from disk as it runs, so the script file is open the whole time and goes bad with everything else. That&amp;rsquo;s why the shell stopped as well.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;A durable handle is how an SMB 2.x client keeps an open file across a dropped connection: the server holds the open for a while, and the client reclaims it after reconnecting. This server granted none. So &amp;ldquo;Reconnect completed successfully&amp;rdquo; is true for the session and useless for every file that was open at the time. Nothing reaches the application except &lt;code&gt;EBADF&lt;/code&gt; on the next read or write.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-fixed-it"&gt;What fixed it &lt;/h2&gt;&#10;&lt;p&gt;Stop running long I/O jobs from the share. The pipeline moved to a USB drive, and only finished files go to the NAS. That ended the failures.&lt;/p&gt;&#10;&lt;p&gt;If you can&amp;rsquo;t move off the share, two workarounds got the job past the failing step:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;Make bash read the whole script before it starts, so it never goes back to the share for the next line:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ... the whole script ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Bash parses the entire &lt;code&gt;{ }&lt;/code&gt; block before running any of it, and the &lt;code&gt;exit&lt;/code&gt; stops it from reading past the closing brace.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;Make the I/O survive a reconnect: process the file in segments (64 MiB worked), open, write and &lt;code&gt;fsync&lt;/code&gt; each segment separately, and on &lt;code&gt;EBADF&lt;/code&gt;, &lt;code&gt;EIO&lt;/code&gt; or &lt;code&gt;EAGAIN&lt;/code&gt; reopen and retry only that segment. With AES-CBC you can chain the segments by using the last ciphertext block as the next IV, and the output is byte-identical to one &lt;code&gt;openssl enc&lt;/code&gt; pass. In the test run on the share, no reconnect happened at all, probably because the per-segment &lt;code&gt;fsync&lt;/code&gt; slowed the writes enough for the NAS to keep up.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;If the server is a Samba box you control, check whether it grants durable handles: the &lt;code&gt;durable handles&lt;/code&gt; option only takes effect with &lt;code&gt;kernel oplocks = no&lt;/code&gt;, &lt;code&gt;kernel share modes = no&lt;/code&gt; and &lt;code&gt;posix locking = no&lt;/code&gt;. Turning off SMB signing on the client (&lt;code&gt;signing_required=no&lt;/code&gt; in &lt;code&gt;/etc/nsmb.conf&lt;/code&gt;) might also take enough load off a weak NAS CPU to prevent the stalls. Neither was tried here.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On macOS, an SMB reconnect to a server without durable handles invalidates every open file on the share, including a running shell script, and the only trace is &lt;code&gt;Missing durable handle&lt;/code&gt; in the kernel log.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;ve seen &lt;code&gt;Errno 9&lt;/code&gt; on an SMB mount, run &lt;code&gt;smbutil statshares -a&lt;/code&gt; and look at &lt;code&gt;SESSION_RECONNECT_COUNT&lt;/code&gt; before you debug your code.&lt;/p&gt;&#10;</description></item><item><title>Zero failures is suspicious: agent checks that silently ran nothing</title><link>https://eghuro.cz/posts/zero-failures-is-suspicious/</link><pubDate>Tue, 29 Sep 2026 12:05:00 +0200</pubDate><guid>https://eghuro.cz/posts/zero-failures-is-suspicious/</guid><description>&lt;p&gt;My coding agent ran each of the two flaky Rust tests 150 times and got this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;streamed_read_right_after_a_rename: 0/150 failed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;renames_are_combined_across_changes_they_do_not_depend_on: 0/150 failed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its next message was &amp;ldquo;Zero failures is suspicious.&amp;rdquo; It was right: neither test had run once. In the same week, three of its checks gave a tidy answer (nothing found, all passed, all failed) that came from a broken command, and each broken command hid its own error.&lt;/p&gt;&#10;&lt;p&gt;If your agent&amp;rsquo;s output has &lt;code&gt;(eval):log:1: too many arguments&lt;/code&gt;, &lt;code&gt;0 passed; 0 failed; … filtered out&lt;/code&gt; or &lt;code&gt;argument list too long&lt;/code&gt; somewhere above the summary, the summary is about a check that never ran.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Claude Code 2.1.283 on macOS, where the agent&amp;rsquo;s shell is zsh 5.9&lt;/li&gt;&#10;&lt;li&gt;A Rust workspace whose test binary is run directly from &lt;code&gt;target/debug/deps&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="trap-1-zsh-has-a-log-builtin"&gt;Trap 1: zsh has a &lt;code&gt;log&lt;/code&gt; builtin &lt;/h2&gt;&#10;&lt;p&gt;The agent was debugging &lt;a&#10; href="https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;an FSKit file system extension&lt;/a&gt;&#10; and searched the unified log for sandbox denials:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;log show --last 30m --style compact --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;MyFS&amp;#34;&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;|&lt;/span&gt; tail -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing came back, and the agent told me: &amp;ldquo;No log entries so far.&amp;rdquo; Half a minute later it ran &lt;code&gt;log stream … &amp;gt; /tmp/fs.log 2&amp;gt;&amp;amp;1 &amp;amp;&lt;/code&gt; and read the file. Its first line was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(eval):log:1: too many arguments&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;zsh has a builtin called &lt;code&gt;log&lt;/code&gt; that lists the logins &lt;code&gt;watch&lt;/code&gt; reports. Builtins win over &lt;code&gt;$PATH&lt;/code&gt;, so &lt;code&gt;log show&lt;/code&gt; never reached &lt;code&gt;/usr/bin/log&lt;/code&gt;, and &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; threw away the only sign of it. You can check your own shell:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; zsh -c &lt;span class="s1"&gt;&amp;#39;whence -wa log&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log: builtin&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log: command&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;/usr/bin/log&lt;/code&gt;, the denial showed up at once, and the agent corrected its earlier claim. The same error turned up in a different project&amp;rsquo;s session days later.&lt;/p&gt;&#10;&#10;&lt;h2 id="trap-2-cargo-test---exact-with-the-wrong-path-passes"&gt;Trap 2: &lt;code&gt;cargo test --exact&lt;/code&gt; with the wrong path passes &lt;/h2&gt;&#10;&lt;p&gt;The 150-run loop called the test binary like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$B&lt;/span&gt; --exact tests::&lt;span class="nv"&gt;$t&lt;/span&gt; -q &amp;gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;f+1&lt;span class="k"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;--exact&lt;/code&gt;, the filter has to equal the full test path. The tests live in &lt;code&gt;vfs::tests&lt;/code&gt;, so &lt;code&gt;tests::streamed_read_right_after_a_rename&lt;/code&gt; matched nothing, and libtest treats that as success:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 93 filtered out; finished in 0.00s&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exit code 0, every time. Without &lt;code&gt;--exact&lt;/code&gt;, the same filter would have matched as a substring. The agent listed the real names with &lt;code&gt;$B --list&lt;/code&gt;, fixed the prefix, and the races then showed up under load.&lt;/p&gt;&#10;&#10;&lt;h2 id="trap-3-an-empty-variable-makes-everything-fail"&gt;Trap 3: an empty variable makes everything fail &lt;/h2&gt;&#10;&lt;p&gt;After the fixes, the agent stress-ran the suite 400 times and looked up the binary first:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;ls -t target/debug/deps/storage_core-* &lt;span class="p"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;&amp;#39;\.d$&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; head -1&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The glob had grown past what &lt;code&gt;ls&lt;/code&gt; accepts:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(eval):1: argument list too long: ls&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;failed runs: 400 / 400&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;$B&lt;/code&gt; was empty. zsh drops an empty unquoted word, so each run executed &lt;code&gt;vfs::tests -q&lt;/code&gt;, got &lt;code&gt;command not found&lt;/code&gt; into a log file nobody read, and counted as a failure. This time the agent saw the error right above the count and called the result bogus.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Make every check prove that it ran before you trust what it says.&lt;/p&gt;&#10;&lt;p&gt;Call &lt;code&gt;/usr/bin/log&lt;/code&gt; by its full path, or put &lt;code&gt;command&lt;/code&gt; in front of it, and keep stderr visible while you are still debugging:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 30m --style compact --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;MyFS&amp;#34;&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; tail -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Count the tests that ran, not only the failures. One test per run should say &lt;code&gt;1 passed&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$B&lt;/span&gt; --exact vfs::tests::&lt;span class="nv"&gt;$t&lt;/span&gt; -q &amp;gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;f+1&lt;span class="k"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -q &lt;span class="s1"&gt;&amp;#39;1 passed&amp;#39;&lt;/span&gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt; did not run&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Get the binary path from cargo instead of a glob, and stop if it is empty:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;cargo &lt;span class="nb"&gt;test&lt;/span&gt; -p storage-core --lib --no-run 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;&amp;#39;target/debug/deps/storage_core-[0-9a-f]*&amp;#39;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:?test binary not found&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For the agent, add one line to its instructions file (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;When a check finds nothing, passes everything, or fails everything, first show that it ran:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;how many items it checked, and that a known-bad input fails it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why-it-happens"&gt;Why it happens &lt;/h2&gt;&#10;&lt;p&gt;All three are the same bug: the harness failed, and the failure looked like a result. A loop that counts exit codes can&amp;rsquo;t tell &amp;ldquo;no failures&amp;rdquo; from &amp;ldquo;no tests&amp;rdquo;, and a pipeline that ends in &lt;code&gt;2&amp;gt;/dev/null | tail&lt;/code&gt; can&amp;rsquo;t tell &amp;ldquo;no matches&amp;rdquo; from &amp;ldquo;no command&amp;rdquo;. Agents write exactly these one-liners, in zsh, many times per session, and they report what the last line printed.&lt;/p&gt;&#10;&lt;p&gt;The agent caught two of the three because the number looked wrong: zero out of 150 for known flaky tests, 400 out of 400 for a suite that had just passed. An empty log search looked plausible, so it went to the user as a finding.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A clean result from a check you haven&amp;rsquo;t seen fail is not evidence. Before you believe &amp;ldquo;nothing found&amp;rdquo; or &amp;ldquo;0 failed&amp;rdquo;, look for the count of what was checked.&lt;/p&gt;&#10;&lt;p&gt;Next time your agent reports zero failures, ask it how many tests actually ran.&lt;/p&gt;&#10;</description></item><item><title>xcodebuild says TEST SUCCEEDED, the xcresult says "Issues recorded without an associated test or suite"</title><link>https://eghuro.cz/posts/xcodebuild-succeeds-xcresult-unassociated-failure/</link><pubDate>Mon, 28 Sep 2026 12:16:00 +0200</pubDate><guid>https://eghuro.cz/posts/xcodebuild-succeeds-xcresult-unassociated-failure/</guid><description>&lt;p&gt;A branch passed locally. &lt;code&gt;xcodebuild test&lt;/code&gt; exited 0 and printed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;** TEST SUCCEEDED **&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The fastlane coverage lane on the same branch failed with &lt;code&gt;Tests have failed&lt;/code&gt; and didn&amp;rsquo;t name a test. Scan&amp;rsquo;s xcpretty output doesn&amp;rsquo;t show Swift Testing results, so the log didn&amp;rsquo;t include any failing tests. The result bundle had the answer:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;xcrun xcresulttool get test-results summary --path Test.xcresult&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;{&amp;#39;result&amp;#39;: &amp;#39;Failed&amp;#39;, &amp;#39;totalTestCount&amp;#39;: 2320, &amp;#39;passedTests&amp;#39;: 2317, &amp;#39;failedTests&amp;#39;: 1, ...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;testName: Issues recorded without an associated test or suite&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;failureText: Issue recorded: failed - Unimplemented: Unimplemented AppVersionClient.currentVersion …&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; This issue was emitted from tests running in a host application.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; This can lead to false positives, where failures could have emitted from live application code at launch time, and not from the current test.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If your console is green and fastlane, Xcode Cloud or anything else that reads the &lt;code&gt;.xcresult&lt;/code&gt; is red, check the summary for that pseudo-test before you go looking for a flaky test.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Xcode 26, Swift Testing, an app-hosted unit test target (tests run inside the real app process).&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/pointfreeco/swift-dependencies"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;swift-dependencies&lt;/a&gt;&#10; 1.14, with a client whose &lt;code&gt;testValue&lt;/code&gt; calls &lt;code&gt;IssueReporting.unimplemented(...)&lt;/code&gt;. That&amp;rsquo;s the usual way to make sure a test sets every dependency it uses.&lt;/li&gt;&#10;&lt;li&gt;The app&amp;rsquo;s root view is a login screen. On appear, it asks a backend for the minimum supported version and compares it with &lt;code&gt;currentVersion()&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-happened"&gt;What happened &lt;/h2&gt;&#10;&lt;p&gt;Because the app hosts the tests, it launches as it does for a user, and its login screen sends the version request. When the response arrives, the screen reads the version client inside a test process, which resolves to the unimplemented test value and records an issue. No test is running on that code path, so the issue gets attached to no test.&lt;/p&gt;&#10;&lt;p&gt;The console run only shows it as a warning:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;⚠︎ Test «unknown» recorded a warning at VersionClient.swift:24:1: Issue recorded&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;↳ failed - Unimplemented: Unimplemented AppVersionClient.currentVersion …&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The result bundle counts it as a failure, though, and fastlane reads it.&lt;/p&gt;&#10;&lt;p&gt;Main didn&amp;rsquo;t fail, and the new branch didn&amp;rsquo;t touch any of this code. Its only addition was a new app-hosted suite that took about seven seconds. That extra time let the response arrive while tests were still running. On main, the run was over before the response came back. This is timing-dependent, so a faster backend or a slower one can hide the failure or bring it back.&lt;/p&gt;&#10;&lt;p&gt;Reproduce one target and compare the result bundle summaries:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Run&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Total&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Failed&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Hosted target, as is&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;21&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Same, new suite skipped (&lt;code&gt;-skip-testing&lt;/code&gt;)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;16&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Same, with the fix&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;20&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The 21 in the first row is 20 real tests plus the pseudo-test.&lt;/p&gt;&#10;&#10;&lt;h2 id="dead-end-set-the-dependency-at-launch"&gt;Dead end: set the dependency at launch &lt;/h2&gt;&#10;&lt;p&gt;The obvious fix is to give the app a live value in its entry point:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;prepareDependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppVersionClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shortVersion&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It built and changed nothing. The same failure showed up again. In a test process, swift-dependencies resets its cached values for each test: it registers an XCTest observer that clears the cache whenever a test case starts. The value you prepare at app launch is gone once the tests start, and the later response reads the test value again.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-dont-show-the-app-ui-while-hosting-tests"&gt;Fix: don&amp;rsquo;t show the app UI while hosting tests &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;isTesting&lt;/code&gt; from IssueReporting (re-exported by Dependencies) is true inside a test process. Gate the scene&amp;rsquo;s content on it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Dependencies&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;SwiftUI&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;MyApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;App&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;some&lt;/span&gt; &lt;span class="n"&gt;Scene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;WindowGroup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Unit tests run inside this app. Its login screen would start a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// network request whose response reads an unimplemented test dependency.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isTesting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;RootView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is the workaround in the library&amp;rsquo;s &lt;a&#10; href="https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/testing#Testing-gotchas"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Testing gotchas&lt;/a&gt;&#10; docs. Before applying it, check that no hosted test needs the app&amp;rsquo;s window or a view in the scene. Here, none did, because the tests only needed a real &lt;code&gt;UIApplication&lt;/code&gt;, which still exists with an empty &lt;code&gt;WindowGroup&lt;/code&gt;. The shipping app is unchanged, since &lt;code&gt;isTesting&lt;/code&gt; is false outside a test run.&lt;/p&gt;&#10;&lt;p&gt;With the fix, the hosted target passed 20 of 20, the whole coverage lane reported 0 failures on both schemes, and the test run stopped sending requests to the dev backend.&lt;/p&gt;&#10;&lt;p&gt;That last part is a nice bonus. A test host that shows its real UI talks to real servers from your CI, and anything it logs (request and response bodies, anti-forgery tokens) ends up in your test logs.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An app-hosted test run also runs your app. Anything the app starts on its own at launch can record a failure that belongs to no test and shows up only in the &lt;code&gt;.xcresult&lt;/code&gt;. Keep the UI out of the test host with &lt;code&gt;if !isTesting&lt;/code&gt;, and when the console and the lane disagree, read &lt;code&gt;xcresulttool get test-results summary&lt;/code&gt; first.&lt;/p&gt;&#10;&lt;p&gt;Try it on your own app-hosted target: grep a recent test log for &lt;code&gt;Test «unknown» recorded&lt;/code&gt;. Each hit is an issue recorded outside any test, and the result bundle may already count it as a failure.&lt;/p&gt;&#10;</description></item><item><title>unattended-upgrades on Debian that installs nothing: Ubuntu-style Allowed-Origins</title><link>https://eghuro.cz/posts/unattended-upgrades-debian-allowed-origins/</link><pubDate>Sat, 26 Sep 2026 19:35:00 +0200</pubDate><guid>https://eghuro.cz/posts/unattended-upgrades-debian-allowed-origins/</guid><description>&lt;p&gt;A Debian 13 server had automatic security updates. Ansible had installed unattended-upgrades, enabled it in &lt;code&gt;20auto-upgrades&lt;/code&gt;, and &lt;code&gt;apt-daily-upgrade.timer&lt;/code&gt; had run that morning. Then a routine check:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ apt list --upgradable 2&amp;gt;/dev/null | grep -c security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;61&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing had failed, and nothing had warned. unattended-upgrades had never found anything it was allowed to install.&lt;/p&gt;&#10;&lt;p&gt;If your &lt;code&gt;50unattended-upgrades&lt;/code&gt; on Debian has &lt;code&gt;Allowed-Origins&lt;/code&gt; with &lt;code&gt;${distro_codename}-security&lt;/code&gt;, yours does the same.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-config"&gt;The config &lt;/h2&gt;&#10;&lt;p&gt;This is what the Ansible role wrote to &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unattended-Upgrade::Allowed-Origins {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}:${distro_codename}&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}:${distro_codename}-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}ESMApps:${distro_codename}-apps-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}ESM:${distro_codename}-infra-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The ESM lines give it away: Extended Security Maintenance is Ubuntu Pro. This is Ubuntu&amp;rsquo;s stock file, copied into a Debian role by hand or by an LLM. On Debian it expands to &amp;ldquo;Debian:trixie&amp;rdquo; and &amp;ldquo;Debian:trixie-security&amp;rdquo;, which looks right and matches nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-it-matches-nothing"&gt;Why it matches nothing &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Allowed-Origins&lt;/code&gt; is the legacy syntax. unattended-upgrades (2.12 on Debian 13) converts each entry to the newer pattern format in &lt;code&gt;get_allowed_origins_legacy()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;allowed_origins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;o=&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;,a=&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distro_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distro_codename&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The part after the colon becomes &lt;code&gt;a=&lt;/code&gt;, and the matcher compares &lt;code&gt;a&lt;/code&gt; with the archive, which is the &lt;code&gt;Suite:&lt;/code&gt; field of the Release file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;what&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;suite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;archive&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fnmatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fnmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Ubuntu, suite and codename are the same string:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# security.ubuntu.com/ubuntu/dists/noble-security/InRelease&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Suite: noble-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Codename: noble&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Debian, they are not:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# security.debian.org/debian-security/dists/trixie-security/InRelease&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Origin: Debian&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Label: Debian-Security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Suite: stable-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Codename: trixie-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;apt-cache policy&lt;/code&gt; shows both, as &lt;code&gt;a=&lt;/code&gt; and &lt;code&gt;n=&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;release v=13,o=Debian,a=stable-security,n=trixie-security,l=Debian-Security,c=main,b=amd64&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So &lt;code&gt;a=trixie-security&lt;/code&gt; matches nothing, and neither does &lt;code&gt;a=trixie&lt;/code&gt;, because the main archive&amp;rsquo;s suite is &lt;code&gt;stable&lt;/code&gt;. Run through unattended-upgrades&amp;rsquo; own matcher against a trixie host&amp;rsquo;s package lists, the legacy entries come up empty, and Debian&amp;rsquo;s patterns each hit their archive:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;o=Debian,a=trixie&amp;#39; -&amp;gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;o=Debian,a=trixie-security&amp;#39; -&amp;gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;origin=Debian,codename=${distro_codename},label=Debian&amp;#39; -&amp;gt; [&amp;#39;stable/trixie&amp;#39;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&amp;#39; -&amp;gt; [&amp;#39;stable-security/trixie-security&amp;#39;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With no allowed origin, there is nothing to upgrade, and that&amp;rsquo;s not an error. Each run logs &lt;code&gt;No packages found that can be upgraded unattended and no pending auto-removals&lt;/code&gt; and exits cleanly.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Use &lt;code&gt;Origins-Pattern&lt;/code&gt; with &lt;code&gt;codename=&lt;/code&gt;, as Debian&amp;rsquo;s own file does:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unattended-Upgrade::Origins-Pattern {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename},label=Debian&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename}-updates,label=Debian&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename},label=Debian-Security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Debian&amp;rsquo;s stock file has the &lt;code&gt;-updates&lt;/code&gt; line commented out. Keep it or drop it as you like.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;a=stable-security&lt;/code&gt; would match too, but don&amp;rsquo;t use it. On release day, &lt;code&gt;stable&lt;/code&gt; moves to the next Debian release, and a suite-based pattern would follow it. A codename stays with the release you installed.&lt;/p&gt;&#10;&lt;p&gt;Then check what unattended-upgrades actually picks:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo unattended-upgrade --dry-run -d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;Allowed origins are:&lt;/code&gt; line shows the patterns after conversion. Below it, the security packages from &lt;code&gt;apt list --upgradable&lt;/code&gt; should show up as upgrade candidates. If the run ends with &amp;ldquo;No packages found&amp;rdquo; while &lt;code&gt;apt list --upgradable&lt;/code&gt; lists &lt;code&gt;stable-security&lt;/code&gt; packages, the origins are still wrong.&lt;/p&gt;&#10;&lt;p&gt;With &lt;code&gt;Automatic-Reboot &amp;quot;false&amp;quot;&lt;/code&gt;, a new kernel or libc still waits for you once updates flow again, so check for &lt;code&gt;/var/run/reboot-required&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On Debian, &lt;code&gt;Allowed-Origins &amp;quot;${distro_id}:${distro_codename}-security&amp;quot;&lt;/code&gt; is a no-op that looks like a security policy, because it matches the suite (&lt;code&gt;stable-security&lt;/code&gt;), not the codename. Use &lt;code&gt;Origins-Pattern&lt;/code&gt; with &lt;code&gt;codename=&lt;/code&gt;, and confirm it with &lt;code&gt;--dry-run -d&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;grep -rn Allowed-Origins /etc/apt/apt.conf.d/&lt;/code&gt; on your Debian hosts, and run the same grep over your configuration management repos, where the copy is most likely to live.&lt;/p&gt;&#10;</description></item><item><title>FSKit after mount -F: "ls: fts_read: Operation not permitted"</title><link>https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted/</link><pubDate>Sat, 26 Sep 2026 16:30:00 +0200</pubDate><guid>https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted/</guid><description>&lt;p&gt;My new FSKit filesystem mounted without complaint, but nothing inside it could be read:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ ls -la ~/mnt/storage-test&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ls: fts_read: Operation not permitted&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;total 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat ~/mnt/storage-test/sub/a.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat: /Users/me/mnt/storage-test/sub/a.txt: Operation not permitted&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;stat&lt;/code&gt; on the mount point worked. If your FSKit extension does the same after a &lt;code&gt;mount -F&lt;/code&gt; from the command line, check its sandbox before your code: the extension can see the source folder&amp;rsquo;s metadata but may not read its contents.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS 26, a file system extension built on FSKit&amp;rsquo;s &lt;code&gt;FSUnaryFileSystem&lt;/code&gt; that exposes a local folder through a mount point&lt;/li&gt;&#10;&lt;li&gt;A small host app, &lt;code&gt;StorageHost.app&lt;/code&gt;, in &lt;code&gt;/Applications&lt;/code&gt;; its executable runs &lt;code&gt;mount -F -t storagefs &amp;lt;source&amp;gt; &amp;lt;mount point&amp;gt;&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;The extension &lt;code&gt;StorageFS.appex&lt;/code&gt; with the FSKit entitlement and the app sandbox&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Info.plist&lt;/code&gt; with &lt;code&gt;FSSupportsPathURLs&lt;/code&gt; set to true and &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; set to false&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The first mount was over &lt;code&gt;~/Downloads&lt;/code&gt;, so the obvious suspect was privacy protection (TCC) for that folder. A fresh &lt;code&gt;~/storage-test&lt;/code&gt; with one file in it failed the same way, which ruled TCC out and pushed the search elsewhere.&lt;/p&gt;&#10;&lt;p&gt;My coding agent&amp;rsquo;s first log queries came back empty, and it told me there were no log entries. That was wrong. In zsh, &lt;code&gt;log&lt;/code&gt; is a builtin, so &lt;code&gt;log show ... 2&amp;gt;/dev/null&lt;/code&gt; runs the builtin, sends its &lt;code&gt;too many arguments&lt;/code&gt; error to &lt;code&gt;/dev/null&lt;/code&gt;, and prints nothing. Call &lt;code&gt;/usr/bin/log&lt;/code&gt; by its full path. With that, the kernel had the answer:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;kernel (Sandbox) 3 duplicate reports for Sandbox: StorageFS(83505) deny(1) file-read-data /Users/me/storage-test&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The extension&amp;rsquo;s own log showed the failures from FSKit&amp;rsquo;s side: &lt;code&gt;readDirectory:...reply:error:1&lt;/code&gt; and &lt;code&gt;readFrom:...reply:error:1&lt;/code&gt;. Error 1 is &lt;code&gt;EPERM&lt;/code&gt;, which &lt;code&gt;ls&lt;/code&gt; turns into &lt;code&gt;fts_read: Operation not permitted&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;mount -F&lt;/code&gt; gives the extension a plain path, not a security-scoped URL, so a sandboxed process can&amp;rsquo;t read an arbitrary path. For development, grant temporary sandbox exceptions in the extension&amp;rsquo;s &lt;code&gt;.entitlements&lt;/code&gt; file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.security.temporary-exception.files.home-relative-path.read-write&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.security.temporary-exception.files.absolute-path.read-write&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Volumes/&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This covers your home folder and external drives. It doesn&amp;rsquo;t override privacy protection, so &lt;code&gt;~/Downloads&lt;/code&gt;, &lt;code&gt;~/Documents&lt;/code&gt;, and &lt;code&gt;~/Desktop&lt;/code&gt; may still be refused; test with a folder elsewhere. Temporary exceptions are for development on your own machine. A shipping extension should use security-scoped resources instead. &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; in &lt;code&gt;Info.plist&lt;/code&gt; requests that; I haven&amp;rsquo;t managed a clean test yet (see below).&lt;/p&gt;&#10;&lt;p&gt;The same logs flagged two more problems that I fixed along the way:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-[FSModuleVolume(Project) getMaxFileSizeInBits]: Volume implements neither maxFileSizeInBits nor maxFileSize. One of them must be implemented.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-[FSVolumeConnector getStandardItemAttributesForItem:replyHandler:]_block_invoke: Reported attributes are incomplete&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first needs &lt;code&gt;maximumFileSizeInBits&lt;/code&gt; on the volume. The second went away once the item attributes set &lt;code&gt;flags&lt;/code&gt;, which cleared the log entry.&lt;/p&gt;&#10;&#10;&lt;h2 id="reinstalling-switches-the-module-off"&gt;Reinstalling switches the module off &lt;/h2&gt;&#10;&lt;p&gt;To test each change, I replaced the app: &lt;code&gt;rm -rf /Applications/StorageHost.app&lt;/code&gt;, copied the new build, and launched it once. After that, &lt;code&gt;mount&lt;/code&gt; failed outright:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Module com.example.storage.fs is disabled!&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mount: Unable to invoke task&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;pluginkit&lt;/code&gt; disagreed. &lt;code&gt;pluginkit -e use -i com.example.storage.fs&lt;/code&gt; succeeded, and &lt;code&gt;pluginkit -m&lt;/code&gt; listed the extension with a &lt;code&gt;+&lt;/code&gt;, which means enabled. FSKit still refused. It keeps its own switch in System Settings → General → Login Items &amp;amp; Extensions → File System Extensions, and I didn&amp;rsquo;t find a command-line way to flip it. After I turned StorageFS back on there, the next mount listed and read files, and appends, renames, truncation, and an 8 MB write-and-readback all worked.&lt;/p&gt;&#10;&lt;p&gt;The disabled module also spoiled my one test of security-scoped resources. With &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; set to true, &lt;code&gt;mount&lt;/code&gt; failed with &lt;code&gt;Unable to invoke task&lt;/code&gt;, but the reinstall had just switched the module off, so the failure says nothing about the setting.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;An FSKit module is an app extension, and FSKit requires it to run in the app sandbox. When an app hands a folder to the extension, sandbox access can travel with it as a security-scoped URL. &lt;code&gt;mount -F&lt;/code&gt; on the command line passes only a string. The sandbox lets the extension look up metadata for that path, which is why the mount and &lt;code&gt;stat&lt;/code&gt; worked, and denies &lt;code&gt;file-read-data&lt;/code&gt;, which every directory listing and file read needs.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;If an FSKit mount works and every read returns &lt;code&gt;Operation not permitted&lt;/code&gt;, search the kernel log for &lt;code&gt;deny(1) file-read-data&lt;/code&gt; with &lt;code&gt;/usr/bin/log&lt;/code&gt;, then give the extension sandbox access to the source path. After every reinstall, check the File System Extensions switch before you trust a test result.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;ve got &lt;code&gt;FSRequiresSecurityScopedPathURLResources&lt;/code&gt; working with a command-line &lt;code&gt;mount -F&lt;/code&gt;, I&amp;rsquo;d like to hear how.&lt;/p&gt;&#10;</description></item><item><title>A failed CUDA build over SSH still launched the old executable</title><link>https://eghuro.cz/posts/remote-failed-build-stale-executable/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/remote-failed-build-stale-executable/</guid><description>&lt;p&gt;A remote command returned a CUDA compilation error followed by a normal simulation startup banner:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;error: calling a __device__ function(&amp;#34;__sinf&amp;#34;) from a __host__ __device__ function(&amp;#34;inflowFluctuation&amp;#34;) is not allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The simulation started, but it ran an older executable. Semicolons let the launch proceed after &lt;code&gt;nvcc&lt;/code&gt; failed. Correcting the source and rebuilding recovered the test; guarding the build-to-launch transition would prevent that misleading result.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://eghuro.cz/posts/claude-code-ssh-openpbs-setup"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;Claude Code and OpenPBS setup&lt;/a&gt;&#10; explains the local-agent/remote-command boundary. This happened during an early SSH-driven test, before we moved the long-running work into scheduled jobs. The shell mistake also applies inside a PBS script.&lt;/p&gt;&#10;&#10;&lt;h2 id="diagnostics-did-not-control-execution"&gt;Diagnostics did not control execution &lt;/h2&gt;&#10;&lt;p&gt;The command had this structure, with project paths removed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nvcc -o solver solver.cu &amp;gt; build.log 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -A3 error build.log &lt;span class="p"&gt;|&lt;/span&gt; head&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nohup ./solver &lt;span class="k"&gt;case&lt;/span&gt;.cfg &amp;gt; run.log 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt; /dev/null &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The build wrote to an executable path that already existed. Compilation failed, leaving a runnable earlier build. Printing matching diagnostics didn&amp;rsquo;t stop the next command, and backgrounding the program didn&amp;rsquo;t establish whether the requested source had compiled.&lt;/p&gt;&#10;&lt;p&gt;The failed-build result included an ordinary banner. After replacing &lt;code&gt;__sinf&lt;/code&gt; with &lt;code&gt;sinf&lt;/code&gt; in the host/device function, the rebuilt program&amp;rsquo;s banner contained the newly added &lt;code&gt;inflowTurb=0.08&lt;/code&gt; parameter and subsequent progress lines. The earlier banner lacked that field. That difference provided evidence of which code path actually ran.&lt;/p&gt;&#10;&lt;p&gt;The transcript shows this operational recovery. Its retry still used semicolon-separated build and launch commands, so it did not repair the fail-open shell structure.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-compiler-status-a-launch-prerequisite"&gt;Make compiler status a launch prerequisite &lt;/h2&gt;&#10;&lt;p&gt;For a future build, preserve the compiler&amp;rsquo;s status explicitly. This generalized Bash pattern builds to a temporary path and only publishes it after success:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT_DIR&lt;/span&gt;&lt;span class="p"&gt;:?Set PROJECT_DIR to the remote project directory&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;candidate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;mktemp ./solver.build.XXXXXX&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;rm -f &amp;#34;$candidate&amp;#34;&amp;#39;&lt;/span&gt; EXIT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; nvcc -o &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; solver.cu &amp;gt; build.log 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; mv -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; ./solver&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;else&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; cat build.log &amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; ./solver &lt;span class="k"&gt;case&lt;/span&gt;.cfg&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is a recommended guard, not code installed during the incident. The named source and configuration must exist in the remote project. Use a single build owner: concurrent publishers to &lt;code&gt;./solver&lt;/code&gt; require a separate ownership or versioned-artifact design.&lt;/p&gt;&#10;&lt;p&gt;When running remotely, send the whole script through SSH so its directory selection and status checks happen in the same remote shell. Avoid embedding local path expansions accidentally. For batch work, run the payload in the allocated job rather than adding another unscheduled &lt;code&gt;nohup&lt;/code&gt; launch.&lt;/p&gt;&#10;&#10;&lt;h2 id="reproduce-the-shell-failure-without-cuda"&gt;Reproduce the shell failure without CUDA &lt;/h2&gt;&#10;&lt;p&gt;On a local machine with a POSIX shell, this harmless fixture leaves an existing command available after a simulated build failure:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sh -c &lt;span class="s1"&gt;&amp;#39;solver() { printf &amp;#34;old executable ran\n&amp;#34;; }; false; solver&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It prints &lt;code&gt;old executable ran&lt;/code&gt; and exits successfully. The guarded form prevents execution:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sh -c &lt;span class="s1"&gt;&amp;#39;solver() { printf &amp;#34;old executable ran\n&amp;#34;; }; false &amp;amp;&amp;amp; solver&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That form prints nothing and returns a failure status. For a real compiler test, deliberately introduce an error and verify that no new run starts. Also log a build identifier or other deliberate runtime identity; a healthy-looking startup line only proves that some executable ran.&lt;/p&gt;&#10;&lt;p&gt;Check your next remote build command for the exact status dependency between compilation and launch, especially where diagnostic pipelines sit between them.&lt;/p&gt;&#10;</description></item><item><title>A second launch of a resumable worker: 'OSError: [Errno 39] Directory not empty'</title><link>https://eghuro.cz/posts/resumable-worker-concurrent-launch/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/resumable-worker-concurrent-launch/</guid><description>&lt;p&gt;A resumable remote dataset driver failed while cleaning an unfinished task directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OSError: [Errno 39] Directory not empty&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Another worker was still writing there. The second launch treated an unfinished task as safe to restart, even though the first process already owned it. Checking the surviving worker and validating its affected output recovered that work unit; no locking fix was installed during the session.&lt;/p&gt;&#10;&lt;p&gt;This was an early detached-run failure in the &lt;a&#10; href="https://eghuro.cz/posts/claude-code-ssh-openpbs-setup"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;Claude Code, SSH, and OpenPBS workflow&lt;/a&gt;&#10;. Scheduler-managed execution later improved job tracking, but an application still needs to prevent conflicting owners.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-waiting-wrapper-had-not-disappeared"&gt;The waiting wrapper had not disappeared &lt;/h2&gt;&#10;&lt;p&gt;The first launch created a detached wrapper that waited for a pilot, then became the production driver. Its structure was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;until&lt;/span&gt; grep -q &lt;span class="s2"&gt;&amp;#34;finished&amp;#34;&lt;/span&gt; pilot.log&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; sleep 30&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; python -u run_dataset.py dataset &amp;gt;&amp;gt; production.log 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before the transition, a search for the production process would not find that driver. After &lt;code&gt;exec&lt;/code&gt;, a search for the wrapper&amp;rsquo;s old command name would no longer identify it. Neither snapshot alone described the complete lifecycle.&lt;/p&gt;&#10;&lt;p&gt;A later check did not establish that the waiting launch was gone. Another detached production command started. The resulting log contained two identical pending-work counts followed by a traceback from &lt;code&gt;shutil.rmtree(gdir)&lt;/code&gt;. Both drivers had selected the same unfinished task, and one attempted cleanup while the other&amp;rsquo;s solver wrote files.&lt;/p&gt;&#10;&lt;p&gt;The resume logic used completion markers to skip finished groups. It offered no exclusive claim on an unfinished directory.&lt;/p&gt;&#10;&#10;&lt;h2 id="recovery-required-checking-the-survivor"&gt;Recovery required checking the survivor &lt;/h2&gt;&#10;&lt;p&gt;The follow-up inspected running processes, restored the affected task&amp;rsquo;s configuration, and checked the surviving computation. Its original log file had been lost during cleanup. A later driver result reported that the task was accepted.&lt;/p&gt;&#10;&lt;p&gt;A broad grep for success also matched text in the traceback. Tightening the check to a result-line pattern distinguished the actual accepted task from an error message that happened to contain matching characters.&lt;/p&gt;&#10;&lt;p&gt;That evidence supports recovery of the affected work unit. It does not prove that no intermediate data was lost or that the session did not add a lock. Eventual production completion checked in a later session also cannot establish that overlapping launches became safe.&lt;/p&gt;&#10;&#10;&lt;h2 id="claim-ownership-before-destructive-resume"&gt;Claim ownership before destructive resume &lt;/h2&gt;&#10;&lt;p&gt;For a single-host driver using a filesystem with working advisory locks, a whole-run lock is one possible improvement. On a system with util-linux &lt;code&gt;flock&lt;/code&gt;, run from the project directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;flock -n .production.lock python -u run_dataset.py dataset&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every launcher must use the same stable lock path. Do not delete and recreate the lock file while work is running. The payload named here is the application&amp;rsquo;s driver, not a standard tool.&lt;/p&gt;&#10;&lt;p&gt;This guard is a new recommendation. A small local check can verify contention without running a dataset:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;fcntl&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;tempfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/worker.lock&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_EX&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_NB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/worker.lock&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_EX&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_NB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;BlockingIOError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;second owner rejected&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;overlapping owner accepted&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Cluster filesystems and multi-node workers need an ownership mechanism tested under their actual locking semantics. Per-task claims can allow parallelism that a whole-run lock forbids. &lt;a&#10; href="https://eghuro.cz/posts/openpbs-resume-stable-shard-ids"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;Stable PBS shard assignment&lt;/a&gt;&#10; solves a different problem: preserving each worker&amp;rsquo;s task set across starts and restarts. It cannot exclude two copies of the same shard.&lt;/p&gt;&#10;&lt;p&gt;Before retrying unfinished work, establish whether a live process already owns it. A missing completion marker only says that completion has not been recorded.&lt;/p&gt;&#10;</description></item><item><title>Claude Code over SSH: submit long-running work to OpenPBS</title><link>https://eghuro.cz/posts/claude-code-ssh-openpbs-setup/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/claude-code-ssh-openpbs-setup/</guid><description>&lt;p&gt;Claude Code can prepare a computation on your laptop and operate a remote cluster through ordinary SSH commands. In this setup, Claude Code ran locally on macOS; source uploads, compilation, queue submissions, and checks ran on a Linux server. OpenPBS handled the long-running computation after submission, so the laptop didn&amp;rsquo;t need to keep an SSH session open.&lt;/p&gt;&#10;&lt;p&gt;The operating rule was explicit: work estimated at one hour or more belonged in PBS. That threshold was a local policy, not an OpenPBS default. Use your site&amp;rsquo;s rules, including its restrictions on short tests and compilation on login nodes.&lt;/p&gt;&#10;&#10;&lt;h2 id="establish-the-ssh-connection-yourself"&gt;Establish the SSH connection yourself &lt;/h2&gt;&#10;&lt;p&gt;Configure authentication and verify the server&amp;rsquo;s host key before asking an agent to connect. For example, in your local &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-sshconfig" data-lang="sshconfig"&gt;Host compute-login&#10; HostName 192.168.0.2&#10; User researcher&#10; BatchMode yes&#10; ConnectTimeout 10&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The address and account are placeholders. With your site&amp;rsquo;s actual values and authentication configured, run this from the laptop:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh compute-login &lt;span class="s1"&gt;&amp;#39;hostname; pwd; command -v qsub; command -v python3&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;BatchMode&lt;/code&gt; makes missing authentication fail instead of waiting for a password. It does not approve the connection or replace host-key verification. Give Claude permission for the intended remote actions through your normal tool-approval workflow; never paste a private key into the conversation.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-execution-boundary-explicit"&gt;Make the execution boundary explicit &lt;/h2&gt;&#10;&lt;p&gt;A useful project instruction is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Claude runs on the laptop. Use SSH for remote operations.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Keep this project&amp;#39;s remote files in ~/batch-demo.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Submit tasks estimated at one hour or more through OpenPBS.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Follow site policy for shorter tests; do not bypass the scheduler.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Record the job ID after submission. Check logs and expected outputs before reporting completion. Ask before cancellation or deletion.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Save this with the project&amp;rsquo;s agent instructions. A prose rule still needs checking: our early sessions launched substantial work directly before the PBS rule was captured. Also, having a GPU on a login host does not grant permission to use it.&lt;/p&gt;&#10;&#10;&lt;h2 id="submit-a-small-smoke-test-first"&gt;Submit a small smoke test first &lt;/h2&gt;&#10;&lt;p&gt;These are generalized setup examples, not a copy of the original scientific workload. Create &lt;code&gt;run.py&lt;/code&gt; locally:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;socket&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;sys&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;worker host: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gethostname&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;python: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;smoke test complete&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Create &lt;code&gt;run.pbs&lt;/code&gt; beside it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#PBS -N agent-smoke&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#PBS -l select=1:ncpus=1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#PBS -l walltime=00:05:00&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#PBS -j oe&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PBS_O_WORKDIR&lt;/span&gt;&lt;span class="p"&gt;:?PBS submission directory missing&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/.venv/bin/python&amp;#34;&lt;/span&gt; -u run.py&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;From the laptop, prepare and submit it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh compute-login &lt;span class="s1"&gt;&amp;#39;mkdir -p ~/batch-demo &amp;amp;&amp;amp; cd ~/batch-demo &amp;amp;&amp;amp; python3 -m venv .venv&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;scp run.py run.pbs compute-login:batch-demo/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh compute-login &lt;span class="s1"&gt;&amp;#39;cd ~/batch-demo &amp;amp;&amp;amp; qsub run.pbs&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Record the exact job ID that &lt;code&gt;qsub&lt;/code&gt; returns. The batch script selects its directory and interpreter without depending on an activated interactive shell. Install real dependencies according to site policy and check that the environment and project directory are accessible on the execution node. Shared paths and compatible Python installations are prerequisites, not promises made by SSH.&lt;/p&gt;&#10;&lt;p&gt;For GPU work, request the site&amp;rsquo;s documented GPU resources. The original array used &lt;code&gt;select=1:ncpus=2:ngpus=1&lt;/code&gt; per element. GPU visibility variables and binding depend on cluster configuration; inspect them inside an allocation rather than choosing an arbitrary device from the login host.&lt;/p&gt;&#10;&#10;&lt;h2 id="follow-the-job-through-its-outputs"&gt;Follow the job through its outputs &lt;/h2&gt;&#10;&lt;p&gt;On the login host, use &lt;code&gt;qstat -f&lt;/code&gt; with the recorded ID for detailed status. Read the output file named in that status. Sites retaining finished-job history may also support &lt;code&gt;qstat -xf&lt;/code&gt; for accounting. The &lt;a&#10; href="https://github.com/openpbs/openpbs/blob/master/doc/man1/qsub.1B"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;upstream qsub manual&lt;/a&gt;&#10; documents submission options and &lt;code&gt;PBS_O_WORKDIR&lt;/code&gt;; the &lt;a&#10; href="https://github.com/openpbs/openpbs/blob/master/doc/man1/qstat.1B"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;qstat manual&lt;/a&gt;&#10; covers status queries.&lt;/p&gt;&#10;&lt;p&gt;An empty queue is insufficient evidence of success. For this smoke test, check the reported worker and Python path and the completion line. For production, also verify expected task outcomes and output contents.&lt;/p&gt;&#10;&lt;p&gt;The subsequent failures were about execution ownership and verification:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://eghuro.cz/posts/remote-failed-build-stale-executable"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;A failed remote build launched an old executable&lt;/a&gt;&#10;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://eghuro.cz/posts/resumable-worker-concurrent-launch"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;A second detached launch collided with resumable work&lt;/a&gt;&#10;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://eghuro.cz/posts/openpbs-resume-stable-shard-ids"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;PBS array resume needed stable task IDs&lt;/a&gt;&#10;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://eghuro.cz/posts/rsync-atomic-archives-incomplete-release"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;Archive transfer started before the release was complete&lt;/a&gt;&#10;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Before handing over a long run, submit the smoke test and check its output on the execution node&amp;rsquo;s actual environment.&lt;/p&gt;&#10;</description></item><item><title>max_retries_exhausted in a scheduled agent: alert on overdue outcomes, not request dumps</title><link>https://eghuro.cz/posts/failed-unattended-agent-jobs-die-silently-into-a-request-dump-graveyard/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/failed-unattended-agent-jobs-die-silently-into-a-request-dump-graveyard/</guid><description>&lt;p&gt;A scheduled agent produced 67 failed-request dumps over roughly two months, yet the archive contains no successful-turn record. The failure reports ended with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;max_retries_exhausted&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;non_retryable_client_error&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Saving the request made diagnosis possible later, but it did nothing to alert an operator to the failed work. To surface unattended jobs, add an independent watcher that alerts when a verified outcome becomes overdue, even if the agent cannot produce a response.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-the-failure-archive-actually-proves"&gt;What the failure archive actually proves &lt;/h2&gt;&#10;&lt;p&gt;The records span five error shapes: a provider-side 503 circuit breaker, a broken pipe, an access-verification service error, a quota rejection, and an invalid-credential response. Counting the dump metadata gives 66 retry-exhaustion failures and one non-retryable client error.&lt;/p&gt;&#10;&lt;p&gt;That is evidence of repeated failed requests. It cannot establish that every scheduled invocation failed: successful work might have been recorded elsewhere, and a request can fail after earlier tool actions succeeded. Nor does the absence of a notification in a dump prove that no external monitor existed.&lt;/p&gt;&#10;&lt;p&gt;The operational problem exposed by this archive is narrower and useful: failure artifacts alone cannot answer, &amp;ldquo;When did the job last finish its work?&amp;rdquo; Because this corpus contains no success receipt to reconcile the errors, it demonstrates no monitoring repair.&lt;/p&gt;&#10;&#10;&lt;h2 id="watch-the-outcome-from-outside-the-agent"&gt;Watch the outcome from outside the agent &lt;/h2&gt;&#10;&lt;p&gt;For each scheduled job, define a durable success receipt tied to its actual acceptance condition. For a capture job, that might mean the destination readback confirms the captured items. A model response saying &amp;ldquo;done&amp;rdquo; is not sufficient.&lt;/p&gt;&#10;&lt;p&gt;Record at least a run ID, start time, terminal status, and the last verified success time. Keep partial progress separate so a retry can reconcile completed writes instead of duplicating them.&lt;/p&gt;&#10;&lt;p&gt;Then run a watcher independently of the model call:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Alert on a terminal failure, including a normalized error class and a run ID.&lt;/li&gt;&#10;&lt;li&gt;Alert when the last verified success exceeds the allowed interval plus a bounded grace period.&lt;/li&gt;&#10;&lt;li&gt;Treat a missing first success as overdue too. A watcher that only checks an existing timestamp can overlook a job that never worked.&lt;/li&gt;&#10;&lt;li&gt;Send the alert through a channel that does not require the failing model provider.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Deduplicate repeated alerts for the same incident, but keep an overdue-success alert active until someone acknowledges or resolves it. If alert delivery fails, track that failure as its own observable status.&lt;/p&gt;&#10;&#10;&lt;h2 id="classify-before-retrying"&gt;Classify before retrying &lt;/h2&gt;&#10;&lt;p&gt;The archive&amp;rsquo;s final credential rejection was classified as non-retryable. That classification is a reason to stop automatic retries and request an authentication/configuration check. It does not prove exactly which configuration change caused the rejection.&lt;/p&gt;&#10;&lt;p&gt;A broken pipe or service outage can justify bounded retries. Quota failures may require waiting for a published reset time. Preserve the specific provider error class: an HTTP status alone does not show whether the next attempt can succeed.&lt;/p&gt;&#10;&lt;p&gt;Pausing should produce an explicit incident state with a resume condition. Otherwise, &amp;ldquo;automatically disabled&amp;rdquo; becomes another silent failure.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-the-monitor-with-deliberately-broken-runs"&gt;Test the monitor with deliberately broken runs &lt;/h2&gt;&#10;&lt;p&gt;In a disposable job, inject a retry-exhaustion failure and a non-retryable credential failure. Confirm both reach the independent alert channel. Suppress the runner entirely and confirm that the overdue-success check still fires. Finally, make the model report success without completing the destination write; that must not refresh the success timestamp.&lt;/p&gt;&#10;&lt;p&gt;Keep raw dumps access-controlled and retention-limited. They can contain the complete outgoing prompt and operational context; send a redacted summary and a restricted evidence reference in alerts.&lt;/p&gt;&#10;&lt;p&gt;Before leaving a job unattended, deliberately prevent one run from starting and check whether anyone notices.&lt;/p&gt;&#10;</description></item><item><title>QtInfoMsg is numerically above QtFatalMsg, so an INFO filter dropped every warning</title><link>https://eghuro.cz/posts/qtinfomsg-above-qtfatalmsg/</link><pubDate>Wed, 23 Sep 2026 16:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/qtinfomsg-above-qtfatalmsg/</guid><description>&lt;p&gt;A desktop app&amp;rsquo;s log file had about 20,000 lines in it, all of them INFO. There wasn&amp;rsquo;t a single WARN or ERROR line, even though the app had plenty of &lt;code&gt;qWarning()&lt;/code&gt; and &lt;code&gt;qCritical()&lt;/code&gt; calls. This was the filter in the custom message handler (trimmed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;Logger&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;logMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QtMsgType&lt;/span&gt; &lt;span class="n"&gt;msgType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;QMessageLogContext&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;QString&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgType&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;minimumType&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;minimumType&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;QtFatalMsg&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;##&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// below the configured level&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It assumes &lt;code&gt;QtMsgType&lt;/code&gt; is ordered by severity. It isn&amp;rsquo;t:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;QtMsgType&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QtDebugMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QtWarningMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 1&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QtCriticalMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QtFatalMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 3&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QtInfoMsg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 4, added in Qt 5.5&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QtSystemMsg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QtCriticalMsg&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;At INFO, &lt;code&gt;minimumType&lt;/code&gt; is &lt;code&gt;QtInfoMsg&lt;/code&gt; (4). Warning (1) and critical (2) are both below 4, so they were thrown away. Info lines got through, so the log looked healthy.&lt;/p&gt;&#10;&lt;p&gt;If your Qt message handler compares &lt;code&gt;QtMsgType&lt;/code&gt; values with &lt;code&gt;&amp;lt;&lt;/code&gt; or &lt;code&gt;&amp;gt;&lt;/code&gt;, it has this bug.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-it-got-there"&gt;How it got there &lt;/h2&gt;&#10;&lt;p&gt;No single commit introduced this:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Before Qt 5.5 the enum &lt;em&gt;was&lt;/em&gt; in severity order, so &lt;code&gt;msgType &amp;lt; minimumType&lt;/code&gt; was correct when the handler was written.&lt;/li&gt;&#10;&lt;li&gt;Qt 5.5 added &lt;code&gt;QtInfoMsg&lt;/code&gt; at the end, to keep the existing values stable. The app also added INFO to the end of its own level enum, and nobody looked at the comparison again.&lt;/li&gt;&#10;&lt;li&gt;Years later, a one-line change moved the fallback level from WARN to INFO. At WARN, the bug had only cost info lines. At INFO it cost warnings and errors, and since INFO was now the default, that became the normal behavior.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Each step looked harmless in its own diff. The cause only showed up when the history was read in order.&lt;/p&gt;&#10;&#10;&lt;h2 id="red-tests-first"&gt;Red tests first &lt;/h2&gt;&#10;&lt;p&gt;Each test sets a level, sends one probe per message type through the real handler to a real log file, and records which probes ended up in the file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// One letter per probe that reached the file: d(ebug), i(nfo), w(arning), c(ritical)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;QString&lt;/span&gt; &lt;span class="nf"&gt;writtenAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Logger&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;setLogLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;qDebug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;probe d&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;qInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;probe i&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;qWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;probe w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;qCritical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;probe c&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;QString&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;logContent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QString&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;QChar&lt;/span&gt; &lt;span class="nl"&gt;probe&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;QStringLiteral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;diwc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QStringLiteral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;probe &amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;TEST_F&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoggerTest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logMessage_InfoLevel_WarningWritten&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ASSERT_EQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;writtenAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Logger&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;iwc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Against the old handler, five of the twelve logger tests failed:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Level&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expected&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Written&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;INFO&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;iwc&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;i&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;WARN&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;wc&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;iwc&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ERROR&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;c&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ic&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;FATAL&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;(none)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;diwc&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The two bottom rows are separate bugs. Info (4) is above every other type, so it got through at WARN and ERROR. The &lt;code&gt;minimumType != QtFatalMsg&lt;/code&gt; exemption meant FATAL wrote &lt;em&gt;everything&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;The fifth failure was a different bug: a level saved as ERROR came back as INFO after a restart. The app&amp;rsquo;s enum key was &lt;code&gt;Error&lt;/code&gt; (an all-caps &lt;code&gt;ERROR&lt;/code&gt; collides with a Windows macro), the setting was stored as &lt;code&gt;ERROR&lt;/code&gt;, and &lt;code&gt;QMetaEnum::keyToValue&lt;/code&gt; is case-sensitive. The lookup failed, so it fell back to the default.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Map the message types to severity explicitly, and compare those numbers instead:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Qt numbers its message types in the order they were added, info last, not by severity&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QtMsgType&lt;/span&gt; &lt;span class="n"&gt;msgType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;QtDebugMsg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;QtInfoMsg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;QtWarningMsg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;QtCriticalMsg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nl"&gt;QtFatalMsg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minimumType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;##&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The FATAL exemption is gone. Each level now means &amp;ldquo;this severity and above.&amp;rdquo; If a future Qt version adds a type, the &lt;code&gt;switch&lt;/code&gt; with no &lt;code&gt;default&lt;/code&gt; makes the compiler warn about it, and the fallback treats the unknown type as most severe, so it gets logged rather than dropped.&lt;/p&gt;&#10;&lt;p&gt;The stored level is now matched without regard to case:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;metaEnum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keyCount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metaEnum&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Qt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CaseInsensitive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&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;LogLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metaEnum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The same twelve tests passed after the fix, and so did the full suite.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-rollout-catch"&gt;The rollout catch &lt;/h2&gt;&#10;&lt;p&gt;Fixing a filter that has silently dropped messages turns on log statements that nobody has read in years. Here, some of those warnings included user file names. The fix couldn&amp;rsquo;t ship until we moved those down to DEBUG. Before you fix a filter like this, grep for every &lt;code&gt;qWarning&lt;/code&gt; and &lt;code&gt;qCritical&lt;/code&gt; call it was dropping, because those are the lines your users will start sending you.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;QtMsgType&lt;/code&gt; values are not ordered by severity. Never compare them with &lt;code&gt;&amp;lt;&lt;/code&gt; or &lt;code&gt;&amp;gt;&lt;/code&gt;. Map them first.&lt;/li&gt;&#10;&lt;li&gt;A log full of INFO lines with no warnings in it is suspicious, not reassuring.&lt;/li&gt;&#10;&lt;li&gt;Test the filter through the real handler with one probe per type, one test per level. The four-letter strings make the whole matrix fit on one screen.&lt;/li&gt;&#10;&lt;li&gt;Check enum-to-string round trips (&lt;code&gt;QMetaEnum::keyToValue&lt;/code&gt;) with an actual save and reload, not just a set and get.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>An UPDATE on encrypted columns wrote nothing for years, and three bugs took turns hiding it</title><link>https://eghuro.cz/posts/encrypted-column-update-three-masked-defects/</link><pubDate>Wed, 23 Sep 2026 15:42:00 +0200</pubDate><guid>https://eghuro.cz/posts/encrypted-column-update-three-masked-defects/</guid><description>&lt;p&gt;A desktop app maintained an activity history in SQLite, but the UI froze on the first entry into any state. An action could start as &amp;lsquo;in progress&amp;rsquo; and later complete, yet the UI would keep showing &amp;lsquo;in progress&amp;rsquo; forever.&lt;/p&gt;&#10;&lt;p&gt;The update function looked fine at first glance, but nobody had actually run it under test, and it had 0% test coverage. That made the bug easy to miss and hard to trust.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;The app encrypts the path columns as it writes them (column level encryption, not the whole database as with SQLCipher; &lt;a&#10; href="https://eghuro.cz/posts/encrypt-sensitive-fields-inside-the-orm"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;see why fields rather than the file&lt;/a&gt;&#10;). With a random IV per row, you can&amp;rsquo;t query the encrypted columns at all. To make rows matchable, derive the IV from the plaintext so the same path always encrypts to the same ciphertext. You store the IV alongside the row, base64 encoded:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;history(id, run_id, path_enc, type, state, timestamp, iv)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can&amp;rsquo;t put a plaintext path in a &lt;code&gt;WHERE&lt;/code&gt; clause against &lt;code&gt;path_enc&lt;/code&gt;. So the update does three steps:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Check the row&amp;rsquo;s IV.&lt;/li&gt;&#10;&lt;li&gt;Re-encrypt the current path with that IV, then &lt;code&gt;SELECT&lt;/code&gt; to check whether the row still matches by id, run, path, and type.&lt;/li&gt;&#10;&lt;li&gt;Run an &lt;code&gt;UPDATE&lt;/code&gt; with the same match predicate.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&#10;&lt;h2 id="reproduce-with-the-real-class"&gt;Reproduce with the real class &lt;/h2&gt;&#10;&lt;p&gt;One test that stores, updates, and reloads through the real DAO, with an in-memory schema and a dummy key:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;TEST_F&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HistoryDaoTest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update_StoredUpload_LoadShowsNewState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;HistoryDao&lt;/span&gt; &lt;span class="nf"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="n"&gt;upload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Upload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;IN_PROGRESS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;EXPECT_EQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&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="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It failed. The reloaded state was still &lt;code&gt;IN_PROGRESS&lt;/code&gt;, which was precisely where the problem was.&lt;/p&gt;&#10;&#10;&lt;h2 id="three-faults-each-hiding-the-next"&gt;Three faults, each hiding the next &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;QByteArray&lt;/span&gt; &lt;span class="n"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queryIv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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="n"&gt;toByteArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 1: still base64&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SELECT id FROM history&amp;#34;&lt;/span&gt; &lt;span class="c1"&gt;// 2: no space before WHERE&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s"&gt;&amp;#34;WHERE id = :id AND path_enc = :path ...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;queryIv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 2: runs the IV query again&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;QSqlQuery&lt;/span&gt; &lt;span class="n"&gt;update_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;update_query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;UPDATE history SET state = :state ... WHERE &amp;lt;same predicate&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 3: runs the SELECT, not the UPDATE&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;The IV was never decoded.&lt;/strong&gt; &lt;code&gt;store()&lt;/code&gt; decodes the base64 IV before encryption, but &lt;code&gt;update()&lt;/code&gt; passed the IV through still encoded. The encrypt helper rejected an IV of the wrong length and threw, so every call stopped there. Before the length check was introduced, a wrong IV would have produced a ciphertext that was impossible to match. In either case, nothing was ever written. The bug had been there since the commit that introduced encryption, because the two sibling functions in that commit disagreed.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The guard SELECT never ran.&lt;/strong&gt; A later refactor concatenated two string literals with no space between them (&lt;code&gt;historyWHERE&lt;/code&gt;), so &lt;code&gt;prepare()&lt;/code&gt; failed. The failure was only logged. Then &lt;code&gt;exec()&lt;/code&gt; ran on the &lt;em&gt;IV query&lt;/em&gt;, which succeeded. A follow-up &amp;ldquo;fix typo in query&amp;rdquo; commit fixed half of the SQL and left the missing space.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The UPDATE never ran.&lt;/strong&gt; A rename to &lt;code&gt;update_query&lt;/code&gt; missed the &lt;code&gt;exec()&lt;/code&gt; call, which kept executing &lt;code&gt;query&lt;/code&gt; instead.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Nothing ever got past fault 1, so faults 2 and 3 were never executed in production. Read the code as a chain of what would have happened if each fault had been fixed one at a time: fix the IV, and the function returns &lt;code&gt;true&lt;/code&gt; while writing nothing; fix the &lt;code&gt;SELECT&lt;/code&gt; as well, and fault 3 reruns a successful &lt;code&gt;SELECT&lt;/code&gt;, commits, and returns &lt;code&gt;true&lt;/code&gt;. Each fix would look like progress, and the result would be a function that reports success and writes nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="date-the-bug-before-you-blame-the-refactor"&gt;Date the bug before you blame the refactor &lt;/h2&gt;&#10;&lt;p&gt;The first suspect was the 2022 refactor that added the second SELECT. Running &lt;code&gt;git log -S&lt;/code&gt; on the decode call and reading &lt;code&gt;store()&lt;/code&gt; and &lt;code&gt;update()&lt;/code&gt; side by side in the encryption commit moved the start date back to 2019. That matters for the fix: the refactor wasn&amp;rsquo;t the thing to revert, and the original rule from before encryption was still the spec.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-tempting-shortcut"&gt;The tempting shortcut &lt;/h2&gt;&#10;&lt;p&gt;The obvious simplification is to drop the IV lookup and the re-encryption and run &lt;code&gt;UPDATE … WHERE id = :id&lt;/code&gt;. That passes the test above.&lt;/p&gt;&#10;&lt;p&gt;It also deletes the rule. The version from before encryption updated a row only while its id, run, paths, and type all still matched. Every later commit kept that predicate. Updating by id alone means a stale action can overwrite whatever row now sits behind that id. So the fix was one small change per fault, keeping the predicate:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;QByteArray&lt;/span&gt; &lt;span class="n"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QByteArray&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryIv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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="n"&gt;toByteArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;QByteArray&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Base64UrlEncoding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SELECT id FROM history &amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s"&gt;&amp;#34;WHERE id = :id AND path_enc = :path ...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;update_query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A second test pins the rule:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;TEST_F&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HistoryDaoTest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update_OtherPath_LeavesTheRowAlone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;HistoryDao&lt;/span&gt; &lt;span class="nf"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="n"&gt;upload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Upload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;IN_PROGRESS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;other.jpg&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// same id, different path&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dao&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;EXPECT_EQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&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="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;IN_PROGRESS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before the fix, this test passed too, trivially, because nothing was ever updated. A negative test proves something only once its positive twin is green. After the fix, both tests passed, and so did the full suite (787 tests).&lt;/p&gt;&#10;&#10;&lt;h2 id="side-notes"&gt;Side notes &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;QSqlQuery::size()&lt;/code&gt; returns &lt;code&gt;-1&lt;/code&gt; on SQLite, because the driver doesn&amp;rsquo;t report query sizes. A &lt;code&gt;if (query.size() &amp;gt; 1)&lt;/code&gt; guard never fires there. Count with &lt;code&gt;while (query.next())&lt;/code&gt;, or use &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Deriving the IV from the plaintext makes the encryption deterministic: equal paths give equal ciphertexts, which is what makes matching possible. With AES-GCM, never reuse that IV for a &lt;em&gt;different&lt;/em&gt; plaintext. If you need equality lookups on encrypted columns, consider a misuse-resistant mode (AES-SIV) or a separate blind-index column (an HMAC of the plaintext) instead.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Test a write path as store → change → reload through the real class against a real database. &amp;ldquo;Returns true&amp;rdquo; and &amp;ldquo;rows changed&amp;rdquo; are different claims.&lt;/li&gt;&#10;&lt;li&gt;Check every &lt;code&gt;prepare()&lt;/code&gt; and &lt;code&gt;exec()&lt;/code&gt; result and stop on failure. A failure that&amp;rsquo;s only logged lets the function carry on and commit.&lt;/li&gt;&#10;&lt;li&gt;Use one &lt;code&gt;QSqlQuery&lt;/code&gt; variable per statement and never pass the wrong one to &lt;code&gt;exec()&lt;/code&gt;. After an &lt;code&gt;UPDATE&lt;/code&gt;, check &lt;code&gt;numRowsAffected()&lt;/code&gt;; zero rows is a valid SQL result, not an error.&lt;/li&gt;&#10;&lt;li&gt;When you fix a chain like this, expect the next fault to surface. Keep the reproducing test red until the whole round trip works.&lt;/li&gt;&#10;&lt;li&gt;Before simplifying a &lt;code&gt;WHERE&lt;/code&gt; clause, find out why each condition is there. Pin the rule with a negative test.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Waiting for logout before login still left a race inside the handshake</title><link>https://eghuro.cz/posts/logout-wait-race-inside-login-handshake/</link><pubDate>Mon, 21 Sep 2026 14:49:00 +0200</pubDate><guid>https://eghuro.cz/posts/logout-wait-race-inside-login-handshake/</guid><description>&lt;p&gt;Correction 2026-10-31: The earlier version attributed the reported failure to an idle timer. Later source review ruled out that timer for this app. The injected-teardown reproduction and exclusion-gate fix remain valid; the production trigger is unknown.&lt;/p&gt;&#10;&lt;p&gt;Users authenticated with Face ID, and the login failed anyway with a generic &amp;ldquo;login failed&amp;rdquo; error. The password was right, and the biometric enrollment was fine. The server&amp;rsquo;s log for the affected user showed the last handshake step arriving on a session in its initial state, with no user bound to it.&lt;/p&gt;&#10;&lt;p&gt;The app waited for any teardown already running before starting login. A dedicated test injected a new teardown after that wait and reproduced the same server-state signature. Holding an exclusion gate for the whole handshake fixed that reproduction, but did not establish the cause of the reported incident.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A stateful, multi-step login (SRP-style): start a session, send the identity, send the proof, finish. The server tracks progress per session cookie.&lt;/li&gt;&#10;&lt;li&gt;A &lt;strong&gt;session teardown&lt;/strong&gt;: sign out on the server, then clear the local cookie jar. &lt;code&gt;begin()&lt;/code&gt; returns immediately, and concurrent &lt;code&gt;begin()&lt;/code&gt; calls join one teardown.&lt;/li&gt;&#10;&lt;li&gt;An earlier fix for a related bug: before its handshake, login called &lt;code&gt;waitForCompletion()&lt;/code&gt; so a sign-out still in flight couldn&amp;rsquo;t destroy the new session.&lt;/li&gt;&#10;&lt;li&gt;The reproduction explicitly calls &lt;code&gt;begin()&lt;/code&gt; during the handshake. It does not rely on an idle timer.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sessionTeardown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waitForCompletion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// once, before the handshake&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// four round trips&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="reproduce-with-the-real-stack"&gt;Reproduce with the real stack &lt;/h2&gt;&#10;&lt;p&gt;A dedicated live test drove the production login code against a development server and fired &lt;code&gt;begin()&lt;/code&gt; at a fixed delay into the handshake. At 500 ms:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;new&amp;#34;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;send identity -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;identified&amp;#34;, &amp;#34;salt&amp;#34;:...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;send proof (M1) -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;proof-verified&amp;#34;, &amp;#34;M2&amp;#34;:...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;logout() &amp;lt;- the teardown fires here&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /logout&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;finish (keys sent)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Logout success&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Session teardown: cookie jar cleared&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error: state &amp;#34;new&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; failed(.other)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This matched the server-state signature logged for the user. In the reproduction, the one-time wait had nothing to wait for: no teardown was running when login started. The test started one between two round trips.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-trap-inside-the-trap-which-effect-does-the-damage"&gt;The trap inside the trap: which effect does the damage &lt;/h2&gt;&#10;&lt;p&gt;The code comments blamed the &lt;strong&gt;cookie-jar clear&lt;/strong&gt;: the concluding step would go out without a session cookie and land on an anonymous session. That would suggest a small fix: defer only the local cleanup.&lt;/p&gt;&#10;&lt;p&gt;In the reproduction&amp;rsquo;s trace, the cookie jar was cleared &lt;em&gt;after&lt;/em&gt; the finish step had already gone out with valid cookies. The &lt;strong&gt;server sign-out&lt;/strong&gt; reset the half-authenticated session, and the concluding step then arrived at a session the server had put back to &lt;code&gt;new&lt;/code&gt;. Deferring only the jar clear would still fail this exact trace.&lt;/p&gt;&#10;&lt;p&gt;Before you choose what to defer, read the ordering on the wire. The local effect you can see easily may not be the one that broke things.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-hold-exclusion-for-the-whole-protocol"&gt;Fix: hold exclusion for the whole protocol &lt;/h2&gt;&#10;&lt;p&gt;The teardown gets a gate. Login holds it from the first round trip to the last. A &lt;code&gt;begin()&lt;/code&gt; that arrives while the gate is held is honored but deferred—neither its sign-out nor its cookie clear runs until the handshake releases the gate.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;TeardownState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;inFlight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;// a count, so overlapping logins can&amp;#39;t release each other&amp;#39;s gate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// join the running one&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// clears inFlight when done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;beginHandshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Wait out a running teardown, then claim the gate. Loop, because the slot&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// is only free on a later pass.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;running&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;endHandshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;excludingTeardown&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;beginHandshake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;endHandshake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Login wraps every credential type in it, so a new login surface can&amp;rsquo;t skip it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sessionTeardown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;excludingTeardown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The other properties stayed the same. &lt;code&gt;begin()&lt;/code&gt; still returns immediately. A second &lt;code&gt;begin()&lt;/code&gt; still means one teardown, whether that teardown is running or held back. A teardown whose sign-out throws still releases its waiters. Logout still happens: it runs right after login, which then ends in a signed-out state on purpose.&lt;/p&gt;&#10;&lt;p&gt;Later source review found that this app created its idle timer only after the full login completed and dropped it when the login screen appeared. The shipped revision had the same wiring. The earlier claim that this timer disproved a signed-in-only assumption was withdrawn. The gate provides exclusion without depending on that reachability argument.&lt;/p&gt;&#10;&lt;p&gt;A sibling app did retain its timer after logout and needed a signed-in guard. That was a separate timer-lifetime defect, not evidence that the timer caused this app&amp;rsquo;s reported failure.&lt;/p&gt;&#10;&#10;&lt;h2 id="verification"&gt;Verification &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Unit test for the new direction.&lt;/strong&gt; A teardown begun inside &lt;code&gt;excludingTeardown&lt;/code&gt; runs neither effect until the body returns. The expected order is &lt;code&gt;[handshake, signOut, clearCookieJar]&lt;/code&gt;. Inside the body, the test awaits &lt;code&gt;waitForCompletion()&lt;/code&gt; rather than &lt;code&gt;Task.yield()&lt;/code&gt;, so a buggy teardown is guaranteed a chance to run. A yield would leave that to the scheduler.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Unit test for the old direction.&lt;/strong&gt; A teardown already running still finishes before the handshake starts. That is the guarantee the earlier fix provided.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Injected-teardown sweep.&lt;/strong&gt; The reproduction fires the teardown at 100, 200, 300, 400, 500 and 700 ms. It had been marked &lt;code&gt;XCTExpectFailure&lt;/code&gt;. After the fix, every delay logged in:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 100ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 200ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 700ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The expected-failure marker was removed in the same commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-this-doesnt-prove"&gt;What this doesn&amp;rsquo;t prove &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The reproduction establishes a failure mechanism and verifies the exclusion fix. It does not prove that this mechanism caused the reported incident or identify its triggering call. We ruled out the idle timer for this app; the field trigger remained unknown.&lt;/li&gt;&#10;&lt;li&gt;The dedicated reproduction and the unit tests verify the fix. The live test class still exited with failures. They came from a separate host-app dependency problem in the test process, and another test in that class still tracks an unrelated second-login bug. Don&amp;rsquo;t read &amp;ldquo;the sweep passes&amp;rdquo; as &amp;ldquo;the live suite is green&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;The ordinary test plan passed, but live tests are opt-in there, so it says nothing about the server interaction.&lt;/li&gt;&#10;&lt;li&gt;Which delays hit the window depends on latency to the server. A sweep is better than a single delay, but it is still a sample.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaways"&gt;Takeaways &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Waiting once for cleanup before a multi-step protocol protects only that instant. If cleanup can &lt;em&gt;start&lt;/em&gt; during the protocol, exclude it for the whole protocol.&lt;/li&gt;&#10;&lt;li&gt;Defer cleanup, don&amp;rsquo;t drop it. Queue the request and run it when the gate is released.&lt;/li&gt;&#10;&lt;li&gt;Count holders instead of using a flag, so two overlapping logins can&amp;rsquo;t release the gate from under each other.&lt;/li&gt;&#10;&lt;li&gt;Read the wire ordering before you choose what to defer. Here, the server sign-out did the damage, not the local cookie clear that everyone suspected.&lt;/li&gt;&#10;&lt;li&gt;A matching reproduction does not identify the production trigger. Trace the initiating call in the affected build before naming it as the cause of an incident.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Hourly agent cron vs. '429 usage_limit_reached': persist the provider's quota reset</title><link>https://eghuro.cz/posts/hourly-agent-cron-vs-provider-quota/</link><pubDate>Sat, 19 Sep 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/hourly-agent-cron-vs-provider-quota/</guid><description>&lt;p&gt;An hourly scheduled agent repeatedly received &lt;code&gt;429 usage_limit_reached&lt;/code&gt; while its provider response already supplied a future reset time. One recorded delay was &lt;code&gt;resets_in_seconds: 202825&lt;/code&gt;, about 2.35 days. The scheduler needed a durable not-before deadline checked before making another model call.&lt;/p&gt;&#10;&lt;p&gt;A failure archive contained 62 quota-related failed-request dumps, plus five failures of other kinds. Those are dump counts, not counts of individual HTTP attempts: a single invocation could retry several times before saving a dump. The archive does not establish that every scheduled invocation failed, and it contains no demonstrated scheduling repair or successful recovery.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-cron-tick-should-consult-shared-cooldown-state"&gt;A cron tick should consult shared cooldown state &lt;/h2&gt;&#10;&lt;p&gt;A retry loop inside one invocation cannot govern the next invocation. When a process exits, an in-memory backoff disappears; the next cron tick can start the same futile sequence.&lt;/p&gt;&#10;&lt;p&gt;Persist the cooldown at the scope of the exhausted quota, such as the provider account and quota bucket. Every worker sharing that bucket must consult it before the first request. A different job name does not create a different quota.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;HTTP Retry-After specification&lt;/a&gt;&#10; permits either an HTTP date or a delay in seconds. Provider JSON fields such as &lt;code&gt;resets_at&lt;/code&gt; and &lt;code&gt;resets_in_seconds&lt;/code&gt; are provider-specific; normalize them in an adapter rather than assuming their units or presence. Use receipt time for relative delays. If multiple applicable constraints exist, do not schedule before any of them.&lt;/p&gt;&#10;&lt;p&gt;An illustrative gate, after parsing and validating those fields:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;math&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;next_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&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="n"&gt;retry_after_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quota_reset_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;margin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;deadlines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_reset_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;deadlines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;no usable deadline; use bounded fallback policy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;deadlines&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isfinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;invalid deadline&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;deadlines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;may_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not_before&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;not_before&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Synthetic clock values; these are not recorded provider timestamps.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_probe&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota_reset_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;202825&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;203830&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;may_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;may_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;may_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# A later response cannot shorten an existing cooldown.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;next_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry_after_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Both applicable constraints survive normalization.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;next_probe&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry_after_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quota_reset_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;9005&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cooldown boundary assertions passed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The gate checks timing only. Production code also needs atomic storage updates, a lock or lease to admit one recovery probe, and clock-skew handling. On restart, read the saved deadline; do not silently treat unreadable state as permission to call. Add bounded jitter after the deadline so workers do not all resume together.&lt;/p&gt;&#10;&#10;&lt;h2 id="give-long-waits-an-operational-policy"&gt;Give long waits an operational policy &lt;/h2&gt;&#10;&lt;p&gt;A near-term transient rate limit can justify a short bounded retry. A multi-day quota exhaustion should explicitly defer the job, with its next eligible probe time visible to an operator. If a provider advertises an implausibly distant reset, escalate instead of silently sleeping forever or truncating the wait and hammering the endpoint.&lt;/p&gt;&#10;&lt;p&gt;At the deadline, admit one probe. A new quota rejection updates the cooldown. An authentication rejection needs intervention rather than the same timed retry. A permitted model response still does not prove the scheduled task completed: reconcile destination writes before replaying work that may have partially succeeded.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-across-process-boundaries"&gt;Test across process boundaries &lt;/h2&gt;&#10;&lt;p&gt;The executable example verifies the early, boundary, and monotonic-deadline cases. To verify a real scheduler, inject a quota response, terminate the worker, and start a new invocation before reset. Assert that no provider call occurs. Start competing workers at reset and assert that only one obtains the recovery lease. Then inject another quota rejection and confirm that it persists the next deadline.&lt;/p&gt;&#10;&lt;p&gt;Stopping repeated calls can hide an overdue job unless monitoring remains independent of the cooldown. The companion post on &lt;a&#10; href="https://eghuro.cz/posts/failed-unattended-agent-jobs-die-silently-into-a-request-dump-graveyard"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;unattended failure alerts&lt;/a&gt;&#10; covers that separate requirement. The disappearance of failure dumps after a reset is not a success receipt.&lt;/p&gt;&#10;&lt;p&gt;Before the next scheduled run, check whether a provider reset deadline survives a worker restart.&lt;/p&gt;&#10;</description></item><item><title>A read with legal effect: listing ISDS messages counts as delivery</title><link>https://eghuro.cz/posts/isds-listing-messages-is-legal-delivery/</link><pubDate>Sun, 13 Sep 2026 14:09:00 +0200</pubDate><guid>https://eghuro.cz/posts/isds-listing-messages-is-legal-delivery/</guid><description>&lt;p&gt;Polling an inbox from a cron job looks harmless, since reading has no side effects. In the Czech Data Box system (ISDS, &lt;em&gt;datové schránky&lt;/em&gt;) it does: the moment an authorized person logs in to the box is the moment every message waiting in it is legally delivered (&lt;em&gt;doručení&lt;/em&gt;). An application counts as that person when it logs in and fetches the list of received messages. A 3 a.m. retrieval of a tax office&amp;rsquo;s request starts the deadline to answer it, whether anyone reads the message or not.&lt;/p&gt;&#10;&lt;p&gt;If your code talks to ISDS, every scheduled retrieval and every copy of your database that still holds the box&amp;rsquo;s password is a legal act.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;An Odoo 18 addon files incoming Data Box messages into the company&amp;rsquo;s document store (the OCA &lt;code&gt;dms&lt;/code&gt; addon). It talks to ISDS over SOAP with zeep (&lt;a&#10; href="https://eghuro.cz/posts/zeep-repeated-sequence-value-1"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;the parsing bug from that client is here&lt;/a&gt;&#10;). A retrieval calls &lt;code&gt;GetListOfReceivedMessages&lt;/code&gt; for the last 100 days and downloads each new message with its signed copy.&lt;/p&gt;&#10;&lt;p&gt;A possible next feature was a cron job, so that the documents appear without anyone pressing a button.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-a-read-starts-a-deadline"&gt;Why a read starts a deadline &lt;/h2&gt;&#10;&lt;p&gt;Act No. 300/2008 Sb. delivers a message in one of two ways. Under § 17(3), it is delivered when a person whose permissions cover it logs in to the box. Under § 17(4), if nobody logs in within 10 days of the message&amp;rsquo;s arrival, it counts as delivered on the last day of that period, which Czech lawyers call delivery by fiction.&lt;/p&gt;&#10;&lt;p&gt;People rely on fiction as a buffer. A box that nobody opens for a week gives you up to 10 extra days before a deadline starts running. A nightly cron throws that buffer away. Every message is delivered the night after it arrives, and its deadline starts then, while the documents sit unread in a folder.&lt;/p&gt;&#10;&lt;p&gt;Not every call has this effect. &lt;code&gt;GetOwnerInfoFromLogin&lt;/code&gt; checks the credentials and returns the box ID and owner without causing delivery. The addon uses it to validate a login before it creates anything.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-design"&gt;The design &lt;/h2&gt;&#10;&lt;p&gt;Once the domain model had the rule written down (&amp;ldquo;Every Retrieval causes Delivery of the Messages waiting in the box&amp;rdquo;), the decisions followed from it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Each connection has a Retrieval Mode, On demand or Scheduled, and On demand is the default.&lt;/li&gt;&#10;&lt;li&gt;The &amp;ldquo;Check data box&amp;rdquo; button asks for confirmation: &amp;ldquo;Checking the data box counts as Delivery (doručení) of every Message waiting in it, which starts legal deadlines. Continue?&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Choosing Scheduled shows a warning, and the form keeps showing it for as long as the connection stays Scheduled. The daily cron job skips On demand connections.&lt;/li&gt;&#10;&lt;li&gt;The one-step setup wizard says that its first retrieval counts as delivery.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="copies-of-the-production-database"&gt;Copies of the production database &lt;/h2&gt;&#10;&lt;p&gt;The usual way to get a realistic test environment for Odoo is to restore a production backup. That copy holds the ISDS username and password, and possibly a scheduled connection. A staging server that runs the cron, or a developer who presses &amp;ldquo;Check data box&amp;rdquo; to see what it does, delivers the production company&amp;rsquo;s official mail.&lt;/p&gt;&#10;&lt;p&gt;Odoo has a hook for this. When you run &lt;code&gt;odoo-bin neutralize&lt;/code&gt;, or restore a database with &lt;strong&gt;Neutralize&lt;/strong&gt; checked in the database manager, it runs each installed module&amp;rsquo;s &lt;code&gt;data/neutralize.sql&lt;/code&gt;. Core modules use it to switch off outgoing mail and crons. The ISDS addon adds its own:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- a neutralized copy must never cause Delivery: drop the Credential passwords&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- and stop Scheduled Retrievals&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dms_isds_connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;retrieval_mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;on_demand&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- unfinished Quick Starts hold a password too, and connecting retrieves at once&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dms_isds_quick_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A retrieval on a neutralized copy fails on the missing password before it contacts ISDS. The test checks exactly that. It runs the module&amp;rsquo;s neutralization queries, presses the button, triggers the cron, and asserts that the ISDS listing was never called:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_neutralize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertRaisesRegex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_check_data_box&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CRON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method_direct_trigger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assert_not_called&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The development setup restores production data and neutralizes it on every refresh, so nobody has to remember.&lt;/p&gt;&#10;&lt;p&gt;Neutralization changes only the copy. The original backup still holds the password in plaintext, so restrict access to backups and encrypt them.&lt;/p&gt;&#10;&#10;&lt;h2 id="other-reads-with-side-effects"&gt;Other reads with side effects &lt;/h2&gt;&#10;&lt;p&gt;ISDS is an extreme case because the side effect is legal. Plenty of other APIs change state when you read:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;IMAP &lt;code&gt;FETCH BODY[]&lt;/code&gt; sets the &lt;code&gt;\Seen&lt;/code&gt; flag; &lt;code&gt;BODY.PEEK[]&lt;/code&gt; doesn&amp;rsquo;t.&lt;/li&gt;&#10;&lt;li&gt;Receiving from a queue hides or consumes the message.&lt;/li&gt;&#10;&lt;li&gt;Opening a message can send a read receipt the sender treats as proof.&lt;/li&gt;&#10;&lt;li&gt;One-time codes and signed links stop working after the first use.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each of these needs the same treatment as outgoing mail: an explicit default, a warning where a person triggers it, and a neutralization step for every copy of the data.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Before you schedule a &amp;ldquo;read-only&amp;rdquo; integration, find out what reading does on the other side. In ISDS, it is legal delivery, so the integration retrieves on demand by default, and a restored database can&amp;rsquo;t log in.&lt;/p&gt;&#10;&lt;p&gt;List the integrations in your own staging setup that still hold production credentials, and check whether any of their reads change something for someone else.&lt;/p&gt;&#10;</description></item><item><title>Zeep returns an empty list: a repeated xs:sequence lands in _value_1</title><link>https://eghuro.cz/posts/zeep-repeated-sequence-value-1/</link><pubDate>Sat, 12 Sep 2026 23:59:00 +0200</pubDate><guid>https://eghuro.cz/posts/zeep-repeated-sequence-value-1/</guid><description>&lt;p&gt;A SOAP client listed received messages and got an empty list on every call, even though the mailbox had messages. No exception or error status occurred, and the unit tests passed. This was the line that read the response:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;dmRecords&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;dmRecord&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The XML on the wire had &lt;code&gt;&amp;lt;dmRecords&amp;gt;&amp;lt;dmRecord&amp;gt;…&amp;lt;/dmRecord&amp;gt;&amp;lt;dmRecord&amp;gt;…&amp;lt;/dmRecord&amp;gt;&amp;lt;/dmRecords&amp;gt;&lt;/code&gt;, but zeep never created a &lt;code&gt;dmRecord&lt;/code&gt; attribute. It put the records in &lt;code&gt;dmRecords._value_1&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If zeep gives you &lt;code&gt;None&lt;/code&gt; or an empty list for a field you can see in the raw response, check whether the schema repeats the &lt;em&gt;sequence&lt;/em&gt; rather than the element.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;An Odoo 18 addon pulls messages from the Czech Data Box system (ISDS), a government SOAP service with published WSDL and XSD files, and talks to it through zeep. I reproduced the behavior below with zeep 4.3.3 on Python 3.13. The list type in the ISDS schema looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;xs:complexType&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tRecordsArray&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;xs:sequence&lt;/span&gt; &lt;span class="na"&gt;minOccurs=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;0&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;maxOccurs=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;unbounded&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;dmRecord&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tns:tRecord&amp;#34;&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;/xs:sequence&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/xs:complexType&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;maxOccurs=&amp;quot;unbounded&amp;quot;&lt;/code&gt; sits on the &lt;code&gt;xs:sequence&lt;/code&gt;, where you would usually expect it on the &lt;code&gt;xs:element&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;zeep maps the two forms differently. Here is a minimal schema with both, parsed with zeep&amp;rsquo;s own types:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;xs:complexType&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tRepeatedSequence&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;xs:sequence&lt;/span&gt; &lt;span class="na"&gt;minOccurs=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;0&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;maxOccurs=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;unbounded&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;record&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tns:tRecord&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;/xs:sequence&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/xs:complexType&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;xs:complexType&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tRepeatedElement&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;xs:sequence&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;xs:element&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;record&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tns:tRecord&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;minOccurs=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;0&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;maxOccurs=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;unbounded&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;/xs:sequence&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/xs:complexType&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Feed both the same two &lt;code&gt;&amp;lt;record&amp;gt;&lt;/code&gt; children, then call &lt;code&gt;serialize_object(..., dict)&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tRepeatedSequence: {&amp;#39;_value_1&amp;#39;: [{&amp;#39;record&amp;#39;: {&amp;#39;id&amp;#39;: &amp;#39;1&amp;#39;}}, {&amp;#39;record&amp;#39;: {&amp;#39;id&amp;#39;: &amp;#39;2&amp;#39;}}]}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tRepeatedElement: {&amp;#39;record&amp;#39;: [{&amp;#39;id&amp;#39;: &amp;#39;1&amp;#39;}, {&amp;#39;id&amp;#39;: &amp;#39;2&amp;#39;}]}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With a repeated element, you get &lt;code&gt;obj.record&lt;/code&gt; as a list. With a repeated sequence, each repetition becomes an item. The sequence has no name, so zeep calls the list &lt;code&gt;_value_1&lt;/code&gt;, and each item is a dict keyed by the element names inside the sequence. A sequence with two elements would give you &lt;code&gt;{&amp;quot;a&amp;quot;: …, &amp;quot;b&amp;quot;: …}&lt;/code&gt; per item.&lt;/p&gt;&#10;&lt;p&gt;The zeep docs explain &lt;code&gt;_value_1&lt;/code&gt; only under &lt;code&gt;xs:choice&lt;/code&gt; and &lt;code&gt;xs:any&lt;/code&gt;. Nothing there tells you that a plain repeated sequence ends up there too.&lt;/p&gt;&#10;&lt;p&gt;Plain attribute access would have failed loudly on the first real call:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;AttributeError: tRepeatedSequence instance has no attribute &amp;#39;record&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;getattr(..., None) or []&lt;/code&gt; was written so that an empty mailbox wouldn&amp;rsquo;t crash. It also handled the case where the attribute doesn&amp;rsquo;t exist at all, and turned it into &amp;ldquo;no messages&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;The tests didn&amp;rsquo;t catch it because they replaced the client&amp;rsquo;s methods with &lt;code&gt;mock.patch&lt;/code&gt;. They never sent a SOAP response through zeep, so they never saw how zeep shapes it.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Read &lt;code&gt;_value_1&lt;/code&gt; and unwrap each item:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;dmRecords&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dmRecord&amp;#34;&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_value_1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[])]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An empty &lt;code&gt;&amp;lt;dmRecords/&amp;gt;&lt;/code&gt; still gives &lt;code&gt;_value_1 == []&lt;/code&gt;, so the empty-mailbox case works.&lt;/p&gt;&#10;&lt;p&gt;To see the shape before you write the parsing code, dump the WSDL with zeep&amp;rsquo;s CLI:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python -m zeep dm_info.wsdl&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ns0:tRecordsArray([dmRecord: ns0:tRecord])&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The square brackets give it away. A repeated sequence prints with brackets around the field list. A repeated element, like &lt;code&gt;tRepeatedElement&lt;/code&gt; above, prints with &lt;code&gt;[]&lt;/code&gt; after the type instead: &lt;code&gt;ns0:tRepeatedElement(record: ns0:tRecord[])&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-the-parsing-with-real-xml"&gt;Test the parsing with real XML &lt;/h2&gt;&#10;&lt;p&gt;The test that covers this now runs canned SOAP XML through the real zeep client, with only the HTTP call patched:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml_body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&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="s2"&gt;&amp;#34;Content-Type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text/xml; charset=utf-8&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&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;soap_envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml_body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;post&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;)]):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;envelopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_received_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;id&amp;#34;&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;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;envelopes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;100001&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100002&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Zeep still loads the WSDL, parses the envelope, and builds its objects, so a mapping mistake like this fails in the test suite with no network. Write the XML from the schema or from a captured response.&lt;/p&gt;&#10;&lt;p&gt;The same commit fixed a second &lt;code&gt;or&lt;/code&gt; default with the same problem: &lt;code&gt;return response.dmSignature or b&amp;quot;&amp;quot;&lt;/code&gt; would have stored a message without its signed copy. It now raises when the signature is missing.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;zeep maps a repeated &lt;code&gt;xs:sequence&lt;/code&gt; to &lt;code&gt;_value_1&lt;/code&gt;, a list of dicts keyed by the inner element names. A &lt;code&gt;getattr(..., None) or []&lt;/code&gt; on top turns that into a silent empty result. Let missing attributes raise, and test SOAP parsing with real XML through zeep.&lt;/p&gt;&#10;&lt;p&gt;Search your zeep code for &lt;code&gt;getattr(&lt;/code&gt; with a &lt;code&gt;None&lt;/code&gt; default followed by &lt;code&gt;or []&lt;/code&gt;, and check each field against the &lt;code&gt;python -m zeep&lt;/code&gt; dump of your WSDL.&lt;/p&gt;&#10;</description></item><item><title>A background agent Docker build: 'open Dockerfile: no such file or directory'</title><link>https://eghuro.cz/posts/background-terminal-jobs-do-not-inherit-cwd/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/background-terminal-jobs-do-not-inherit-cwd/</guid><description>&lt;p&gt;A background Docker build failed immediately with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR: failed to solve: failed to read dockerfile: open Dockerfile: no such file or directory&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The agent had supplied the project directory through the terminal tool&amp;rsquo;s &lt;code&gt;workdir&lt;/code&gt; argument. The retry put &lt;code&gt;cd&lt;/code&gt; inside the command itself. For an agent runner whose background path handling is uncertain, bind the directory in the command or make the Dockerfile and build context absolute.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-failed-call-already-supplied-workdir"&gt;The failed call already supplied workdir &lt;/h2&gt;&#10;&lt;p&gt;In the historical runner, the background invocation had this shape, with project and image names generalized:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;background&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;workdir&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/srv/app&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;command&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docker build --progress=plain -t local/app:dev -f Dockerfile .&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Docker could not open &lt;code&gt;Dockerfile&lt;/code&gt;. The retry started with an explicit &lt;code&gt;cd&lt;/code&gt; to the project before running the build. That corrected the launch form; whether the rest of that session succeeded is outside this lesson.&lt;/p&gt;&#10;&lt;p&gt;The error alone does not identify the actual launch directory. The record supports a runner-specific path-handling failure, not a rule that background processes generally discard their parent&amp;rsquo;s directory. Ordinary shell backgrounding with &lt;code&gt;&amp;amp;&lt;/code&gt; inherits the shell&amp;rsquo;s working directory. An agent tool can launch its jobs through a different execution path.&lt;/p&gt;&#10;&lt;p&gt;A later check on the runner used to prepare this post found that background jobs preserved the preceding foreground directory, and an explicit background &lt;code&gt;workdir&lt;/code&gt; was honored. The historical failure is therefore not a current universal behavior claim.&lt;/p&gt;&#10;&#10;&lt;h2 id="bind-both-docker-paths-deliberately"&gt;Bind both Docker paths deliberately &lt;/h2&gt;&#10;&lt;p&gt;For an existing project at &lt;code&gt;/srv/app&lt;/code&gt;, put the directory change and build in the same command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; docker build --progress&lt;span class="o"&gt;=&lt;/span&gt;plain -t local/app:dev -f Dockerfile .&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; prevents the build from running if the directory change fails. Alternatively, remove the directory dependency from both path arguments:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker build --progress&lt;span class="o"&gt;=&lt;/span&gt;plain -t local/app:dev &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -f /srv/app/Dockerfile /srv/app&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Making only &lt;code&gt;-f&lt;/code&gt; absolute still leaves &lt;code&gt;.&lt;/code&gt; as an accidental build context. Likewise, when using Compose, check its file selection and relative-path behavior rather than assuming a successful Docker build proves the next command has the right context.&lt;/p&gt;&#10;&#10;&lt;h2 id="probe-the-actual-execution-mode"&gt;Probe the actual execution mode &lt;/h2&gt;&#10;&lt;p&gt;Before a multi-minute job, use a harmless foreground command to change to a known scratch directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="p"&gt;:?Set TMPDIR to your scratch directory&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then submit &lt;code&gt;pwd&lt;/code&gt; as a separate background tool call without &lt;code&gt;workdir&lt;/code&gt;, and read that job&amp;rsquo;s captured output. Repeat with &lt;code&gt;workdir&lt;/code&gt; set to the project root. Those two checks distinguish remembered foreground state from an explicit launch directory without building an image or starting services.&lt;/p&gt;&#10;&lt;p&gt;For a real build, print &lt;code&gt;pwd&lt;/code&gt; and check &lt;code&gt;test -f Dockerfile&lt;/code&gt; inside the same command that launches it. A foreground check in an earlier call cannot establish the background job&amp;rsquo;s directory.&lt;/p&gt;&#10;&#10;&lt;h2 id="separate-service-startup-from-readiness-checks"&gt;Separate service startup from readiness checks &lt;/h2&gt;&#10;&lt;p&gt;The same historical session hit another runner boundary. A foreground command combining &lt;code&gt;docker compose up -d&lt;/code&gt; with a health-check loop was rejected with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;This foreground command appears to start a long-lived server/watch process.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The runner directed the agent to launch it with &lt;code&gt;background=true&lt;/code&gt;, verify readiness, and run tests separately. That refusal came from the runner, even though Compose&amp;rsquo;s &lt;code&gt;-d&lt;/code&gt; requests detached containers.&lt;/p&gt;&#10;&lt;p&gt;Keep service startup in its own call, following the runner&amp;rsquo;s execution rules. Check readiness in a separate bounded command, and fail explicitly if the deadline expires. Starting a service and proving it healthy are separate results.&lt;/p&gt;&#10;&lt;p&gt;Before your next background build, compare its reported directory with the intended build context using the harmless probe above.&lt;/p&gt;&#10;</description></item><item><title>Uninstalling one Odoo module can remove its dependents and their data</title><link>https://eghuro.cz/posts/uninstalling-one-module-can-cascade/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/uninstalling-one-module-can-cascade/</guid><description>&lt;p&gt;An Odoo module replacement passed a rehearsal&amp;rsquo;s record-count and accounting-total checks, yet the uninstall also removed a dependent addon that owned important business data. The application still loaded. The acceptance checks had missed the field that mattered.&lt;/p&gt;&#10;&lt;p&gt;Before replacing a module, inspect its reverse dependencies and data ownership. Rehearse the corrected transition on a restored copy, then compare the affected values by record identity before approving the live change.&lt;/p&gt;&#10;&#10;&lt;h2 id="uninstall-computes-a-reverse-dependency-set"&gt;Uninstall computes a reverse-dependency set &lt;/h2&gt;&#10;&lt;p&gt;In &lt;a&#10; href="https://github.com/odoo/odoo/blob/18.0/odoo/addons/base/models/ir_module.py"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Odoo 18&amp;rsquo;s module implementation&lt;/a&gt;&#10;, &lt;code&gt;downstream_dependencies()&lt;/code&gt; recursively finds modules that depend on the selected modules. &lt;code&gt;button_uninstall()&lt;/code&gt; marks both the selection and those dependents as &lt;code&gt;to remove&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The same source describes &lt;code&gt;module_uninstall()&lt;/code&gt; as removing database structures created by the module, including tables and columns. Actual deletion depends on the affected records and metadata; do not assume every business record disappears, or that surviving rows retain every field.&lt;/p&gt;&#10;&lt;p&gt;Generalize the replacement to a full addon, a smaller replacement, and an extension that depends on the full addon. Installing the smaller addon does not automatically redirect the extension&amp;rsquo;s dependency. Uninstalling the full addon still includes that extension in the removal set.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-first-rehearsal-had-the-wrong-acceptance-criteria"&gt;The first rehearsal had the wrong acceptance criteria &lt;/h2&gt;&#10;&lt;p&gt;The historical transition checked surviving records, aggregate totals, and registry health. Those checks passed while the dependent addon was also uninstalled. The user caught the missing preservation requirement and required a restore.&lt;/p&gt;&#10;&lt;p&gt;The subsequent investigation found a second problem: changing the extension&amp;rsquo;s manifest alone would be insufficient. The smaller replacement did not yet implement a field the extension required. That field&amp;rsquo;s behavior needed to be ported before changing the dependency.&lt;/p&gt;&#10;&lt;p&gt;The retained record includes the corrective restore and a later clone&amp;rsquo;s uninstall/registry-reload output. It ends with the corrected rehearsal and final verification still open. It does not prove a successful final transition or a completed before/after digest comparison.&lt;/p&gt;&#10;&#10;&lt;h2 id="rehearse-the-field-handoff-explicitly"&gt;Rehearse the field handoff explicitly &lt;/h2&gt;&#10;&lt;p&gt;A safer transition procedure is:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Quiesce writes and capture a consistent database and filestore backup. Verify checksums and prove that the pair restores.&lt;/li&gt;&#10;&lt;li&gt;Record installed module states, reverse dependencies, and the fields and records that must survive. Keep the addon source revisions with the backup.&lt;/li&gt;&#10;&lt;li&gt;Implement the required field behavior in the replacement, including compute and reversal behavior where applicable. Version-control the dependent addon too.&lt;/li&gt;&#10;&lt;li&gt;On an isolated restore, install the replacement and upgrade the dependent addon so the database&amp;rsquo;s module metadata reflects the new dependency. Inspect the removal set again before uninstalling the old addon.&lt;/li&gt;&#10;&lt;li&gt;Complete the uninstall, reload the registry, and compare the required values and behavior. Investigate field metadata and ownership during the handoff; identical field names alone do not establish safe transfer.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Disable scheduled jobs and outbound integrations on the clone. A rehearsal should not send mail, contact payment services, or write to production storage.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-identity-and-value-not-only-counts"&gt;Compare identity and value, not only counts &lt;/h2&gt;&#10;&lt;p&gt;For a small affected dataset, compare canonical exports directly. For larger exports, a digest is convenient if the query scope, ordering, and serialization remain identical.&lt;/p&gt;&#10;&lt;p&gt;This synthetic Python check demonstrates why counts and non-null counts are insufficient. It models two records whose field values were exchanged; it is not an Odoo migration test:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;hashlib&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;json&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;,&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;utf-8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-20&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-20&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;counts match; identity/value digest detects the change&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For a real comparison, export the stable record ID alongside each affected field, preserve nulls, and normalize dates and other types explicitly. A missing column must fail the check. Also test compute behavior and important workflows: a value digest cannot prove those work.&lt;/p&gt;&#10;&lt;p&gt;Before your next uninstall, list which dependent addons and field values must remain, and make their survival a rehearsal acceptance criterion.&lt;/p&gt;&#10;</description></item><item><title>ISDOC.PDF: an XML invoice in a PDF, and the VAT date it can't structure</title><link>https://eghuro.cz/posts/isdoc-pdf-line-level-tax-date/</link><pubDate>Sun, 09 Aug 2026 05:06:00 +0200</pubDate><guid>https://eghuro.cz/posts/isdoc-pdf-line-level-tax-date/</guid><description>&lt;p&gt;Czech VAT law lets you put a month of separate supplies on one tax document, as long as each supply states its own date. ISDOC, the national e-invoicing format, has one tax date per document. Move that element onto an invoice line and validation fails:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Element &amp;#39;TaxPointDate&amp;#39;: This element is not expected.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Expected is one of ( Note, VATNote, Item, ... )&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A human reading the PDF sees every date. The accounting software importing the XML sees one.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-isdoc-is"&gt;What ISDOC is &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://isdoc.cz"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;ISDOC&lt;/a&gt;&#10; is an XML invoice format, free to use, and maintained by the Czech Ministry of the Interior as the &amp;ldquo;national standard for electronic invoicing.&amp;rdquo; The current version is 6.0.2 from March 2022. &lt;a&#10; href="https://github.com/isdoc/schema/blob/main/FAQ.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Many accounting programs&lt;/a&gt;&#10; open it directly, so nobody retypes the invoice. A &lt;a&#10; href="https://mf.gov.cz/cs/dane-a-ucetnictvi/elektronicka-fakturace/zakladni-informace"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;2018 Ministry of Finance notice&lt;/a&gt;&#10; also lists ISDOC 5.2 and later among the formats central government bodies must accept.&lt;/p&gt;&#10;&lt;p&gt;A document comes in &lt;a&#10; href="https://isdoc.cz/6.0.2/doc/isdoc.html#reprezentace"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;three representations&lt;/a&gt;&#10;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;.isdoc&lt;/code&gt;: plain XML in UTF-8&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;-isdoc.pdf&lt;/code&gt;: the XML embedded in a PDF/A-3, known as ISDOC.PDF&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;.isdocx&lt;/code&gt;: an archive with a manifest and attachments&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;ISDOC.PDF is the practical choice when you don&amp;rsquo;t know the recipient&amp;rsquo;s software. It opens like any PDF, and software that understands the format extracts the XML. German ZUGFeRD and French Factur-X use the same idea.&lt;/p&gt;&#10;&#10;&lt;h2 id="making-an-isdocpdf"&gt;Making an ISDOC.PDF &lt;/h2&gt;&#10;&lt;p&gt;The spec&amp;rsquo;s requirements for the PDF:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The PDF is PDF/A-3&lt;strong&gt;a&lt;/strong&gt;, and the file name ends in &lt;code&gt;-isdoc.pdf&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The embedded XML is named exactly &lt;code&gt;invoice.isdoc&lt;/code&gt;, with subtype &lt;code&gt;text/xml&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Its &lt;code&gt;/Filespec&lt;/code&gt; is listed both in the catalog&amp;rsquo;s &lt;code&gt;/AF&lt;/code&gt; array and in the &lt;code&gt;/EmbeddedFiles&lt;/code&gt; name tree.&lt;/li&gt;&#10;&lt;li&gt;The &lt;code&gt;/Filespec&lt;/code&gt; has &lt;code&gt;/F&lt;/code&gt;, &lt;code&gt;/UF&lt;/code&gt; and &lt;code&gt;/EF&lt;/code&gt;, and &lt;code&gt;/EF&lt;/code&gt; holds both &lt;code&gt;F&lt;/code&gt; and &lt;code&gt;UF&lt;/code&gt; stream entries.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;/AFRelationship&lt;/code&gt; is &lt;code&gt;/Source&lt;/code&gt; if the PDF was rendered from the XML, or &lt;code&gt;/Alternative&lt;/code&gt; if both came from your database.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Generate the XML first, since LaTeX has to find it at compile time. With &lt;code&gt;pdflatex&lt;/code&gt;, the preamble is short:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-latex" data-lang="latex"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;\usepackage&lt;/span&gt;&lt;span class="na"&gt;[a-3b]&lt;/span&gt;&lt;span class="nb"&gt;{&lt;/span&gt;pdfx&lt;span class="nb"&gt;}&lt;/span&gt; &lt;span class="c"&gt;% needs \jobname.xmpdata next to the .tex&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;\usepackage&lt;/span&gt;&lt;span class="nb"&gt;{&lt;/span&gt;embedfile&lt;span class="nb"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;\embedfile&lt;/span&gt;[&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; filespec=invoice.isdoc,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; mimetype=text/xml,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; afrelationship=Alternative&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;]&lt;span class="nb"&gt;{&lt;/span&gt;invoice.isdoc&lt;span class="nb"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you already have a PDF, the &lt;a&#10; href="https://github.com/isdoc/isdoc.pdf"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;official repository&lt;/a&gt;&#10; has a Java tool that joins a PDF and an ISDOC file and links to a Ghostscript-based script. Its &lt;code&gt;examples/output&lt;/code&gt; folder is the reference to compare against.&lt;/p&gt;&#10;&lt;p&gt;The ministry &lt;a&#10; href="https://github.com/isdoc/schema/blob/main/FAQ.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;doesn&amp;rsquo;t run a validator&lt;/a&gt;&#10;, so check the output yourself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;xmllint --noout --schema isdoc-invoice-6.0.2.xsd invoice.isdoc&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfdetach -list invoice-isdoc.pdf &lt;span class="c1"&gt;# 1: invoice.isdoc&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfdetach -save &lt;span class="m"&gt;1&lt;/span&gt; -o extracted.isdoc invoice-isdoc.pdf&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp extracted.isdoc invoice.isdoc&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfinfo -meta invoice-isdoc.pdf &lt;span class="p"&gt;|&lt;/span&gt; grep pdfaid&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfinfo invoice-isdoc.pdf &lt;span class="p"&gt;|&lt;/span&gt; grep Tagged&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These commands don&amp;rsquo;t prove PDF/A conformance. For that, use a validator such as veraPDF. XSD validation also doesn&amp;rsquo;t run ISDOC&amp;rsquo;s Schematron rules.&lt;/p&gt;&#10;&#10;&lt;h3 id="what-the-checks-turned-up"&gt;What the checks turned up &lt;/h3&gt;&#10;&lt;p&gt;Our generator&amp;rsquo;s XML validated, and we extracted its attachment byte for byte. The PDF still differs from the spec in two ways, as a comparison with an official sample showed:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Official sample&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;code&gt;pdfx&lt;/code&gt; + &lt;code&gt;embedfile&lt;/code&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;pdfaid&lt;/code&gt; conformance&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;B&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Tagged&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;yes&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;no&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;/EF&lt;/code&gt; keys&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;F&lt;/code&gt;, &lt;code&gt;UF&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;F&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;A tolerant reader won&amp;rsquo;t care, and &lt;code&gt;pdfdetach&lt;/code&gt; didn&amp;rsquo;t. A strict one could. PDF/A-3a needs a fully tagged document, which this &lt;code&gt;pdflatex&lt;/code&gt; setup doesn&amp;rsquo;t produce.&lt;/p&gt;&#10;&lt;p&gt;Three other traps:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Optional validation can skip silently.&lt;/strong&gt; Our &lt;code&gt;--xsd&lt;/code&gt; flag printed &amp;ldquo;validation skipped&amp;rdquo; and carried on, because &lt;code&gt;lxml&lt;/code&gt; was missing. In CI, make a skipped check fail.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;TaxPointDate&lt;/code&gt; is not the issue date.&lt;/strong&gt; A summary document can go out up to 15 days after the end of the month, so the two differ by design.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Signing happens before embedding.&lt;/strong&gt; The ISDOC signature is an enveloped XMLDSig inside the XML. The transform removes only the &lt;code&gt;Signature&lt;/code&gt; element, so reformatting the file afterward breaks the digest. A signature on the PDF is a separate step and should be an incremental update, which leaves the embedded bytes alone.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-the-law-requires-and-the-standard-lacks"&gt;What the law requires and the standard lacks &lt;/h2&gt;&#10;&lt;p&gt;The summary tax document (&lt;em&gt;souhrnný daňový doklad&lt;/em&gt;, § 31b of Act No. 235/2004 Sb.) covers several separate supplies to one customer within a calendar month. Under § 31b(4), it must state separately for each supply its scope, &lt;em&gt;date&lt;/em&gt;, unit price, tax base, rate and tax, and for each received payment its &lt;em&gt;date of receipt&lt;/em&gt;. Data common to all of them can appear once (§ 31b(3)), so a header date is enough only if every supply happened on the same day.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;VAT Act&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;ISDOC 6.0.2&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Gap&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 31b(4)(a)(2): date of each supply&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;TaxPointDate&lt;/code&gt; on &lt;code&gt;Invoice&lt;/code&gt; only&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No structured field per line&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 31b(4)(b)(2): date of each payment&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;TaxedDeposit&lt;/code&gt; has no date&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No structured field per payment&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 31b: a separate kind of document&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;DocumentType&lt;/code&gt; 1–7, none for it, and the spec&amp;rsquo;s definition of &amp;ldquo;tax document&amp;rdquo; doesn&amp;rsquo;t list it&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Receiver can&amp;rsquo;t tell from the standard fields&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 29(1)(h): date of supply on a tax document&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;TaxPointDate&lt;/code&gt; is optional in the XSD, and only the spec&amp;rsquo;s prose says to fill it&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;XSD accepts a document without it&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 26(3): customer consents to electronic form&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ElectronicPossibilityAgreementReference&lt;/code&gt; is mandatory, but empty validates&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Presence is checked, consent is not&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 34: origin and integrity&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Optional XML signature&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;None: a recognized signature is one route among several&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;I tested the per-line and document-type rows by editing a valid file: &lt;code&gt;TaxPointDate&lt;/code&gt; on a line, &lt;code&gt;TaxPointDate&lt;/code&gt; on an item, and &lt;code&gt;DocumentType&lt;/code&gt; 8 each failed the XSD. The payment row comes from reading &lt;code&gt;TaxedDepositType&lt;/code&gt;. This is my reading of the statute and the schema, not a ruling or tax advice. Nor does it make an ISDOC.PDF unlawful. If the visible document prints every date, the data is on the document. The gap is that the machine-readable half can&amp;rsquo;t carry it in a form a recipient&amp;rsquo;s software will understand.&lt;/p&gt;&#10;&lt;p&gt;The spec says its rules should, where possible, be independent of legislation. That is a fair position for validation rules. It doesn&amp;rsquo;t help when the structure has nowhere to put a value the law requires.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-implementations-have-the-same-shape"&gt;The implementations have the same shape &lt;/h2&gt;&#10;&lt;p&gt;Header-only dates aren&amp;rsquo;t unique to ISDOC. In Odoo 18, &lt;code&gt;account.move.line.date&lt;/code&gt; is a read-only related field that mirrors the journal entry&amp;rsquo;s date, and the Czech localization adds a single &lt;code&gt;taxable_supply_date&lt;/code&gt; to the entry, defaulting to today. Modeling this correctly took a dedicated per-line field and a rule that derives the header from it. &lt;a&#10; href="https://github.com/isdoc/schema/issues/7"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;The upstream issue&lt;/a&gt;&#10; also notes that many commercial accounting systems lack per-line dates.&lt;/p&gt;&#10;&lt;p&gt;Our generator has its own limits: one VAT rate per invoice, CZK only, no deposits, and no reverse charge.&lt;/p&gt;&#10;&lt;p&gt;EN 16931, the European e-invoicing norm, offers a partial precedent. It has an &lt;a&#10; href="https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-InvoiceLine/cac-InvoicePeriod"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;invoice line period&lt;/a&gt;&#10; (BG-26) with start and end dates. A line period is not a tax date, but it shows that per-line dates fit in an invoice model.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-to-do-today"&gt;What to do today &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Print each supply&amp;rsquo;s date in the visible PDF. That is the part a person, or a tax auditor, reads.&lt;/li&gt;&#10;&lt;li&gt;Set &lt;code&gt;TaxPointDate&lt;/code&gt; to the last supply date. Section 31b doesn&amp;rsquo;t define a document-level date, but the &lt;a&#10; href="https://adisspr.mfcr.cz/dpr/adis/idpr_pub/dph/kh1/pokyny/pokyny_dphkh1_2024.pdf"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;2024 VAT control statement instructions&lt;/a&gt;&#10; ask for &amp;ldquo;the last (calendar) date&amp;rdquo; of the supplies on a summary document, so this keeps the two consistent.&lt;/li&gt;&#10;&lt;li&gt;Put each date on its own line in a fixed format. Both of these validate:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Note&amp;gt;&lt;/span&gt;Datum plnění: 2026-07-14&lt;span class="nt"&gt;&amp;lt;/Note&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Item&amp;gt;&amp;lt;Description&amp;gt;&lt;/span&gt;Work A&lt;span class="nt"&gt;&amp;lt;/Description&amp;gt;&amp;lt;/Item&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Extensions&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;x:TaxPointDate&lt;/span&gt; &lt;span class="na"&gt;xmlns:x=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;urn:example:line-tax-point&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;2026-07-14&lt;span class="nt"&gt;&amp;lt;/x:TaxPointDate&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/Extensions&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ol start="4"&gt;&#10;&lt;li&gt;Agree on the format with any recipient who needs to import the dates. A standard importer won&amp;rsquo;t read your note or your namespace.&lt;/li&gt;&#10;&lt;li&gt;If a recipient needs structured dates and can&amp;rsquo;t parse those, issue one ordinary invoice per date. It costs you the summary document, but the data is exact.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The proper fix looks small: in a new schema version, an optional &lt;code&gt;TaxPointDate&lt;/code&gt; on &lt;code&gt;InvoiceLine&lt;/code&gt; and a date on payments, with the header kept as it is. &lt;a&#10; href="https://github.com/isdoc/schema/issues/7"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Issue #7&lt;/a&gt;&#10; reports the missing per-line date and is open.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;XSD-valid doesn&amp;rsquo;t mean legally complete, and legally complete doesn&amp;rsquo;t mean importable. Before you adopt an exchange format for regulated data, list what the law requires per record and find the element that holds each field. Any requirement that ends up in a free-text note is one your recipient&amp;rsquo;s software can&amp;rsquo;t see.&lt;/p&gt;&#10;</description></item><item><title>A dedup lookup that fails reads as "not found": urllib.parse.quote() is not query escaping</title><link>https://eghuro.cz/posts/dedup-lookup-quote-is-not-query-escaping/</link><pubDate>Thu, 23 Jul 2026 01:05:00 +0200</pubDate><guid>https://eghuro.cz/posts/dedup-lookup-quote-is-not-query-escaping/</guid><description>&lt;p&gt;An OCR&amp;rsquo;d invoice number with an apostrophe in it, say &lt;code&gt;O'BRIEN-1&lt;/code&gt;, went into a duplicate check like this one (trimmed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;flt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;firma=&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;firma&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#39; and cisDosle=&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cis_dosle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#39;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;faktura-prijata/(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;).json?detail=custom:id,kod&amp;amp;limit=1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;winstrom&amp;#34;&lt;/span&gt;&lt;span class="p"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;faktura-prijata&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entries&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;FlexiBeeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The server rejected the filter with &lt;code&gt;HTTP 400&lt;/code&gt; (&amp;ldquo;Špatný formát WQL&amp;rdquo;, bad WQL format). The &lt;code&gt;except&lt;/code&gt; turned that into &lt;code&gt;None&lt;/code&gt;, the caller read &lt;code&gt;None&lt;/code&gt; as &amp;ldquo;no such invoice yet&amp;rdquo;, and pushed it again: a second copy of the invoice in the books, and no error anywhere.&lt;/p&gt;&#10;&lt;p&gt;If your client puts a filter expression into a URL and wraps it in &lt;code&gt;quote()&lt;/code&gt;, the apostrophe problem is probably yours too.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;The API is the REST interface of ABRA Flexi, a Czech ERP, called from Python&amp;rsquo;s standard library. Its filters are a small query language (WQL) carried in the URL path: &lt;code&gt;evidence/(field='value').json&lt;/code&gt;. The &lt;code&gt;?filter=&lt;/code&gt; query-string form is silently ignored, so the path it is used instead.&lt;/p&gt;&#10;&lt;p&gt;The values come from OCR and a language model. The invoice number was normalized for dashes and whitespace only, so an apostrophe made it into the filter intact.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;quote()&lt;/code&gt; did its job. It turned the expression into something safe to put in a URL:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cisDosle=&amp;#39;O&amp;#39;BRIEN-1&amp;#39;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;cisDosle%3D%27O%27BRIEN-1%27&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The server URL-decodes the path before it parses the filter, so the parser gets back &lt;code&gt;cisDosle='O'BRIEN-1'&lt;/code&gt;. The literal ends after &lt;code&gt;O&lt;/code&gt;; the rest is garbage, and the query fails.&lt;/p&gt;&#10;&lt;p&gt;Percent-encoding protects the URL, and the server undoes it before the filter language ever sees the value.&lt;/p&gt;&#10;&lt;p&gt;Every client in the codebase built its filters this way, including the MCP server. Two call sites even disagreed on &lt;code&gt;safe=&lt;/code&gt; (&lt;code&gt;&amp;quot;'=&amp;quot;&lt;/code&gt; versus &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;), which made no difference, since the server decodes either form back to the same bytes.&lt;/p&gt;&#10;&lt;p&gt;The failure stayed hidden because of the &lt;code&gt;except&lt;/code&gt;. A lookup that can&amp;rsquo;t answer reported &amp;ldquo;not found&amp;rdquo;, and for a duplicate check, &amp;ldquo;not found&amp;rdquo; means &amp;ldquo;write it&amp;rdquo;.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;First, find out what the grammar&amp;rsquo;s escape actually is. No client library for this API escapes it (the PHP one interpolates values raw), so there was nothing to copy. A wrong guess would cause the very duplicate the lookup is there to prevent, so it was settled by asking the server, read-only, in a sandbox company, with values that can&amp;rsquo;t match any record:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cisDosle=&amp;#39;ZZZTEST&amp;#39; balanced -&amp;gt; 200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cisDosle=&amp;#39;ZZZ&amp;#39;TEST&amp;#39; raw (the bug) -&amp;gt; 400 Špatný formát WQL … pozice 15&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cisDosle=&amp;#39;ZZZ\&amp;#39;TEST&amp;#39; backslash -&amp;gt; 200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cisDosle=&amp;#39;ZZZ&amp;#39;&amp;#39;TEST&amp;#39; SQL-style doubling -&amp;gt; 400&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cisDosle=&amp;#39;A\\B&amp;#39; doubled backslash -&amp;gt; 400&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cisDosle=&amp;#39;A\B&amp;#39; lone backslash -&amp;gt; 400&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So this grammar escapes an apostrophe with a backslash, the way C does, not by doubling it, the way SQL and OData do. A backslash escapes nothing else, which means a literal backslash can&amp;rsquo;t be written at all. That&amp;rsquo;s acceptable for invoice numbers, codes, company IDs, and dates, which never carry one.&lt;/p&gt;&#10;&lt;p&gt;Second, build every filter in one module, so the escape exists exactly once:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;urllib.parse&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;escape_literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;object&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&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;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#39;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;&amp;#39;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&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;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;object&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;escape_literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#39;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;and_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;clauses&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34; and &amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clauses&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evidence_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evidence&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;expr&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;).json&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evidence_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;faktura-prijata&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;and_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;firma&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firma&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cisDosle&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cis_dosle&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;escape_literal&lt;/code&gt; handles the query language and &lt;code&gt;quote&lt;/code&gt; handles the URL, each in one place. Against the sandbox, the real client with this filter now gets a &lt;code&gt;200&lt;/code&gt; for an invoice number with an apostrophe.&lt;/p&gt;&#10;&lt;p&gt;Before the fix, the gap was parked as an &lt;code&gt;expectedFailure&lt;/code&gt; test. It checks that the quotes in the generated filter balance once either escape form is removed, so it would pass whichever escape the probes picked, and it flipped to an unexpected pass the moment the fix landed.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;Any value that goes into a query language needs that language&amp;rsquo;s escape, and then the transport&amp;rsquo;s. Percent-encoding is the transport&amp;rsquo;s. OData &lt;code&gt;$filter&lt;/code&gt; and most search APIs set the same trap with their own escape rules, so probe the server instead of guessing.&lt;/p&gt;&#10;&lt;p&gt;The other half is the &lt;code&gt;except&lt;/code&gt;. The finder in this codebase still maps an error to &lt;code&gt;None&lt;/code&gt;, and that remains on its list of known issues. If you write a duplicate check, give it three outcomes: found, not found, and couldn&amp;rsquo;t tell, and make the caller stop on the third.&lt;/p&gt;&#10;&lt;p&gt;An earlier bug in the same codebase took the other route to the same end: a bank-movement duplicate check used &lt;code&gt;LIKE&lt;/code&gt;, which this API doesn&amp;rsquo;t support. The server didn&amp;rsquo;t reject it; it returned an empty list, so every movement was pushed a second time. The fix was the API&amp;rsquo;s &lt;code&gt;BEGINS&lt;/code&gt; operator, plus a one-off script to delete the copies.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Escape a value for the query language first and for the URL second; &lt;code&gt;quote()&lt;/code&gt; only does the second. A duplicate check that turns errors into &amp;ldquo;not found&amp;rdquo; creates a duplicate every time it breaks.&lt;/p&gt;&#10;&lt;p&gt;Search your code for &lt;code&gt;quote(f&amp;quot;&lt;/code&gt; and check each hit for a quoted literal built from user or OCR input.&lt;/p&gt;&#10;</description></item><item><title>HTTP 500 that still commits: never retry an ambiguous write</title><link>https://eghuro.cz/posts/http-500-ambiguous-write-retry/</link><pubDate>Wed, 22 Jul 2026 22:29:00 +0200</pubDate><guid>https://eghuro.cz/posts/http-500-ambiguous-write-retry/</guid><description>&lt;p&gt;We ran the client&amp;rsquo;s real retry loop against a fake server and counted the writes on the wire, before and after the fix:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;before: push PUT 500 -&amp;gt; 200 writes_on_wire=2 -&amp;gt; reported ok&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;after: push PUT 500 -&amp;gt; 200 writes_on_wire=1 -&amp;gt; FlexiBeeError(maybe_committed=True)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The fake server played what the real API does now and then: it answers a PUT with &lt;code&gt;500 Internal Server Error&lt;/code&gt; after it has already saved the invoice. The client retried; the second PUT came back &lt;code&gt;200 OK&lt;/code&gt;, and the batch reported success. On a real API, that invoice is on the books twice. If your API can return a 5xx after committing, a retry that succeeds is the worst outcome you can get: you end up with a duplicate and nothing in the logs.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;The API was the REST interface of ABRA Flexi, an ERP, called from Python through &lt;code&gt;urllib&lt;/code&gt; and &lt;code&gt;http.client&lt;/code&gt;. It returns 500 now and then after the write has gone through. That much was known; one client already looked the record up by document number after a failure.&lt;/p&gt;&#10;&lt;p&gt;Five hand-written clients for that API were merged into one transport layer with a shared &lt;code&gt;RetryPolicy&lt;/code&gt;. An AI coding agent did the merge, two commits after writing an ADR that warned about duplicate writes.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&#10;&lt;h3 id="the-policy-didnt-know-the-method"&gt;The policy didn&amp;rsquo;t know the method &lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;RetryPolicy&lt;/code&gt; decided from the response alone, so it retried a 500 on a PUT the same way it retried one on a GET. The test for that case fed in three 500s and checked only the flag on the final error. It pinned the number of retries and never asked whether a write should be retried in the first place.&lt;/p&gt;&#10;&#10;&lt;h3 id="the-safety-nets-sat-outside-the-loop"&gt;The safety nets sat outside the loop &lt;/h3&gt;&#10;&lt;p&gt;The first fix made the method a required argument and turned off retries of ambiguous writes by default. Two callers opted back in, because each had a safety net. In outline:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# MCP server: an idempotency key per tool call&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;write_tool&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="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;idem_seen&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="c1"&gt;# checked once, before the first attempt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached_result&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&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="s2"&gt;&amp;#34;PUT&amp;#34;&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;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# retries happen in here&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;idem_record&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# recorded only after success&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# webhook worker: look the record up when the write fails&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;PUT&amp;#34;&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;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# retries happen in here&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;find_by_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;committed-via-verify&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A review five days later, by a different model, caught it: &amp;ldquo;_idem_seen runs before this function, and _idem_record runs only after success, so neither prevents this retry from duplicating a write.&amp;rdquo; Likewise, the GET check runs only when &lt;code&gt;request()&lt;/code&gt; raises, and a 500 followed by a successful retry never raises. Both checks were correct; the retry loop inside &lt;code&gt;request()&lt;/code&gt; simply never consulted them.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Writes are never retried on an ambiguous outcome, and no caller can opt back in. The error goes up to the caller and says the write may have committed. The policy, trimmed to the part that matters:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;WRITE_METHODS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;POST&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;PUT&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;PATCH&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;DELETE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetryPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="c1"&gt;# required: a default would let the next client forget it&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_is_write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;WRITE_METHODS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;next_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&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="n"&gt;body&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_rate_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="c1"&gt;# the server refused: nothing committed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;rate-limit&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_is_write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;transient-5xx&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="c1"&gt;# a 5xx on a write: raise, maybe_committed=True&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;next_retry_for_network_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# no response at all: the server may have processed it before the connection died&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_is_write&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;network&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The new test counts requests, because a retry that succeeds looks exactly like a first attempt that did:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_500_on_a_write_is_never_retried&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fake&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FakeConn&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;FakeResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;boom&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;FakeResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_get_conn&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertRaises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;invoice.json&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;invoice&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{}]})&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requests_made&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maybe_committed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Reads still retry, and they make up most of the traffic in a batch. A write that comes back &lt;code&gt;maybe_committed&lt;/code&gt; is checked by whoever started it, before anything resends it.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;The policy asked, &amp;ldquo;may I retry a 5xx?&amp;rdquo; For a write, the question is &amp;ldquo;may I resend something whose outcome is unknown?&amp;rdquo; Two outcomes leave a write ambiguous: a 5xx, and no response at all. A 429, or a 400 rejecting a stale user role, is the server refusing the write; nothing committed, so a retry can&amp;rsquo;t duplicate anything.&lt;/p&gt;&#10;&lt;p&gt;The HTTP method doesn&amp;rsquo;t settle it either. RFC 9110 calls PUT idempotent, and urllib3&amp;rsquo;s default &lt;code&gt;allowed_methods&lt;/code&gt; includes PUT, so a &lt;code&gt;status_forcelist=[500, 502, 503]&lt;/code&gt; resends it. This API&amp;rsquo;s PUT to a collection creates a record each time.&lt;/p&gt;&#10;&lt;p&gt;If you do want writes retried automatically, the check has to run between the failure and the resend: before every attempt, look the record up or send a key the server itself deduplicates on. A check before the first call or after the last one can&amp;rsquo;t see the retries in between.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A 500 on a write means you don&amp;rsquo;t know what happened, so pass that up the stack instead of resending. Test it by counting requests on the wire, since the final result looks the same either way.&lt;/p&gt;&#10;&lt;p&gt;Find the retry loop in your own HTTP client and check whether it knows the method it is retrying.&lt;/p&gt;&#10;</description></item><item><title>Standards vs Spec review: a second agent found duplicate writes past 1,572 green tests</title><link>https://eghuro.cz/posts/standards-vs-spec-review/</link><pubDate>Wed, 22 Jul 2026 21:58:00 +0200</pubDate><guid>https://eghuro.cz/posts/standards-vs-spec-review/</guid><description>&lt;p&gt;This is what the review agent reported, just before it listed its findings:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;The test evidence is clean: Ruff passes, Python reports 1,572 passed / 56 skipped /&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1 expected failure, and the GUI reports 418 passed / 4 skipped. The review nevertheless&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;found safety gaps the tests do not exercise — most importantly, two write clients retry&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#34;500-after-commit&amp;#34; before their advertised idempotency/verification guard can run.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A coding agent had built the branch: 53 commits, 147 files. A different model reviewed it and found two paths that could put the same invoice in the ERP twice and report plain success. No test covered them, and no linter could. They came from a reviewer with one job: check the code against what the branch said it does.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;The repository vendors the two-axis &lt;code&gt;code-review&lt;/code&gt; skill from &lt;a&#10; href="https://github.com/mattpocock/skills"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;mattpocock/skills&lt;/a&gt;&#10;. It pins a fixed point and diffs against the merge base, &lt;code&gt;git diff master...HEAD&lt;/code&gt;, then forks two subagents in parallel:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Standards: does the diff follow the repo&amp;rsquo;s documented rules (glossary, ADRs, a baseline of Fowler code smells)?&lt;/li&gt;&#10;&lt;li&gt;Spec: does the diff do what the originating issue or PRD asked for?&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The parent agent runs lint and tests meanwhile, &amp;ldquo;as supporting evidence, not as a substitute for the diff review&amp;rdquo;. It then reports the two axes under separate headings and doesn&amp;rsquo;t rerank them, so a pile of naming nits can&amp;rsquo;t bury a P1.&lt;/p&gt;&#10;&lt;p&gt;The Spec brief is short:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Report: (a) requirements the spec asked for that are missing or partial;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(b) behavior in the diff that wasn&amp;#39;t asked for (scope creep);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(c) requirements that look implemented but where the implementation looks wrong.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Quote the spec line for each finding, under 400 words.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="what-each-axis-found"&gt;What each axis found &lt;/h2&gt;&#10;&lt;p&gt;There was no PRD for the branch, so the Spec reviewer used the commit messages and the ADRs. One commit claimed that the MCP server&amp;rsquo;s idempotency check made it safe to retry a write that got a 5xx. The reviewer traced the call: the check ran before &lt;code&gt;_request()&lt;/code&gt;, the key was recorded after success, and the retry happened inside &lt;code&gt;_request()&lt;/code&gt;, in between. A webhook worker had the same shape, with a verify-by-GET that ran only if the request finally raised. A retry that succeeds never raises. (&lt;a&#10; href="https://eghuro.cz/posts/http-500-ambiguous-write-retry"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;The full HTTP 500 story is here&lt;/a&gt;&#10;.)&lt;/p&gt;&#10;&lt;p&gt;The Standards reviewer found something else. Two functions called &lt;code&gt;urlopen()&lt;/code&gt; directly, past the shared transport that an ADR says owns all retries. It also found an import that went through a module the ADRs forbid as a re-export facade, and an unfinished vocabulary migration.&lt;/p&gt;&#10;&lt;p&gt;None of the Standards findings overlapped with the Spec ones. All six were fixed the same evening, in one commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-tests-stayed-green"&gt;Why the tests stayed green &lt;/h2&gt;&#10;&lt;p&gt;The tests came from the same author as the claim. No test let a 500 commit the write, let the retry succeed, and counted the writes, because the commit said the idempotency check covered that case. A suite tests what its author thought could break.&lt;/p&gt;&#10;&lt;p&gt;The Spec reviewer starts from the claim instead. A commit message says the code is safe, so it goes looking for the line that makes it safe, and here that line ran on the wrong side of the retry.&lt;/p&gt;&#10;&#10;&lt;h2 id="two-more-runs-same-pattern"&gt;Two more runs, same pattern &lt;/h2&gt;&#10;&lt;p&gt;In the next review, a few hours later on a six-commit range, a guard was undone downstream. Every write had to carry exactly one confidence label, and &lt;code&gt;create_*&lt;/code&gt; checked that. But when label setup had failed, a fallback further down stripped all labels from the payload. The reviewer reproduced a labeled invoice leaving without its label. It also found a write that went through a raw &lt;code&gt;put()&lt;/code&gt; and skipped the scope check that limits which record types may be written at all.&lt;/p&gt;&#10;&lt;p&gt;The third review had nothing to go on but commit messages. The branch added a gate: if an invoice is addressed to another company, warn and refuse to post it. The reviewer said it was inferring the spec from the commits, so I pasted the original request and the Spec subagent re-checked against that. The request held a second requirement the commit summary had left out, which turned out to be implemented. The review also found that a replay of staged payloads called the push function directly and skipped the new gate. And while company registration numbers were checksum-validated, VAT IDs relied on regex alone, so one misread digit could block a legitimate invoice. Both failed the first test, then got fixed.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-to-use-it"&gt;How to use it &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Run Standards and Spec as separate subagents, so neither sees the other&amp;rsquo;s findings, and report them under separate headings.&lt;/li&gt;&#10;&lt;li&gt;Give the Spec reviewer the authoritative spec. If you only have commit messages, say so, and swap in the real request the moment you have it.&lt;/li&gt;&#10;&lt;li&gt;Treat claims in commit messages and ADRs (&amp;ldquo;X makes this safe&amp;rdquo;, &amp;ldquo;Y owns all retries&amp;rdquo;) as requirements. They are the cheapest spec you have, and the author&amp;rsquo;s tests already agree with them.&lt;/li&gt;&#10;&lt;li&gt;If you can, review with a different model than the one that wrote the code. It is less likely to share the writer&amp;rsquo;s blind spots.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A green suite tells you the code does what its author expected. A Spec review by a second agent tells you whether that matches what the author promised. The duplicate writes were in the gap between the two.&lt;/p&gt;&#10;&lt;p&gt;Take the last branch your agent wrote, and have a second agent check each &amp;ldquo;this makes X safe&amp;rdquo; in its commit messages against the code.&lt;/p&gt;&#10;</description></item><item><title>Date the bug before you park it: fence, drift or silence</title><link>https://eghuro.cz/posts/date-the-bug-before-you-park-it/</link><pubDate>Fri, 17 Jul 2026 01:13:00 +0200</pubDate><guid>https://eghuro.cz/posts/date-the-bug-before-you-park-it/</guid><description>&lt;p&gt;My coding agent was splitting a 738-line Python function into steps and found a real bug along the way:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That line rebinds the list instead of extending it, so it dropped every issue recorded before validation, including the note that repair mode had just deleted a record in the accounting system. The push still succeeded, and the operator&amp;rsquo;s report said nothing about the delete.&lt;/p&gt;&#10;&lt;p&gt;The agent didn&amp;rsquo;t fix it. It wrote a known-issues entry and added two &lt;code&gt;@unittest.expectedFailure&lt;/code&gt; tests, because the handoff for the refactor said: &amp;ldquo;characterize, then collapse, then fix separately&amp;rdquo;. It was the wrong call, and two &lt;code&gt;git log -S&lt;/code&gt; runs showed it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Claude Code working in a Python repository of an accounting pipeline, tested with &lt;code&gt;unittest&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;A long-lived function where several steps append to &lt;code&gt;result.issues&lt;/code&gt;, the operator&amp;rsquo;s per-file report;&lt;/li&gt;&#10;&lt;li&gt;A refactor rule that a split must not change behavior.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-parking-costs"&gt;What parking costs &lt;/h2&gt;&#10;&lt;p&gt;An &lt;code&gt;expectedFailure&lt;/code&gt; marker looks like a decision. The next reader sees a documented, tested, known gap and assumes someone weighed it and chose to keep it. If nobody did, the marker turns an accident into policy and keeps a live bug in the tree behind it.&lt;/p&gt;&#10;&lt;p&gt;So before you park a bug, find out whether anyone ever decided it. I asked the agent to run the fence check (Chesterton&amp;rsquo;s fence: don&amp;rsquo;t remove a fence until you know why it was put up).&lt;/p&gt;&#10;&#10;&lt;h2 id="date-the-bug"&gt;Date the bug &lt;/h2&gt;&#10;&lt;p&gt;A bug is a relationship between two things: this line and the code it gets wrong — date both.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log --oneline -S&lt;span class="s1"&gt;&amp;#39;result.issues = validation.issues&amp;#39;&lt;/span&gt; -- &amp;lt;path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log --oneline -S&lt;span class="s1"&gt;&amp;#39;result.issues.append&amp;#39;&lt;/span&gt; -- &amp;lt;path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The result went into the fix&amp;rsquo;s commit message:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-16 rebind written. result.issues had NO prior writers;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; assigning validation&amp;#39;s list into it lost nothing.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-19 + vendor mismatch trail above it +3d&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-23 + REPAIR delete note above it +7d&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-24 + registry retry verdict above it +8d&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-05-27 + self-billed reroute above it +41d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The rebind was correct when it was written. Over the next 41 days, four commits added writers above it, and none of them touched it. Nobody decided to discard those issues; the code predates the thing it breaks.&lt;/p&gt;&#10;&lt;p&gt;That gives one of three verdicts:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Fence&lt;/strong&gt;: someone decided this, in a commit message, a spec&amp;rsquo;s non-goals or an ADR. Leave it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Drift&lt;/strong&gt;: the code predates what it is wrong about, or its author never touched the site. Fix it now, and put the dates in the commit message so a reviewer can see you didn&amp;rsquo;t remove a fence.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Silence&lt;/strong&gt;: the history says nothing either way. Park it, and say plainly that nobody decided it.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This one was drift, so it got fixed the same night. It had stayed hidden because the only test that reached this code patched the whole function body out. The split made the body reachable, and the first end-to-end repair test found the bug.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-obvious-fix-was-wrong"&gt;The obvious fix was wrong &lt;/h2&gt;&#10;&lt;p&gt;A drift verdict says nobody meant the bug; you still have to find out what depends on it. The one-word fix:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;would have broken an alias. A later step appends registry failures to &lt;code&gt;result.issues&lt;/code&gt;, and those reach the invoice&amp;rsquo;s note only because the rebind made &lt;code&gt;result.issues&lt;/code&gt; and &lt;code&gt;validation.issues&lt;/code&gt; the same list, which the note builder reads:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="c1"&gt;# one list, two names&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;apply_registry_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# appends to result.issues ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;build_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# ... and the note sees it through the alias&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;.extend()&lt;/code&gt;, every invoice that failed the business-registry check would have lost that warning from its note: one bug traded for a quieter one. The fix kept the rebind and put the earlier issues back after the later writers ran:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;earlier_issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;apply_registry_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ... the other writers ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;earlier_issues&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The tests were written to catch both mistakes: reverting the fix fails the four tests that check the earlier issues survive, and the naive &lt;code&gt;.extend()&lt;/code&gt; fails the one that checks the registry warning still reaches the note. The known-issues entry was deleted, and the &lt;code&gt;expectedFailure&lt;/code&gt; markers became ordinary tests.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-silence-looks-like"&gt;What silence looks like &lt;/h2&gt;&#10;&lt;p&gt;That night, four other bugs were dated as drift and fixed. One wasn&amp;rsquo;t. Debug files were named from the input&amp;rsquo;s stem, so &lt;code&gt;invoice.docx&lt;/code&gt; and &lt;code&gt;invoice.pdf&lt;/code&gt; in one batch wrote the same file, and the later one won. That naming shipped on a pipeline that already read several formats, so the code didn&amp;rsquo;t predate the collision. No commit message or spec mentioned it either: &lt;code&gt;stem&lt;/code&gt; is just the obvious way to name a sidecar file. That one stayed a known-issues entry, with the dating written into it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-skill"&gt;The skill &lt;/h2&gt;&#10;&lt;p&gt;The fence check is a Claude Code skill I had written down earlier that evening. The agent had it and didn&amp;rsquo;t run it until asked, so its memory now says to run it the moment it finds a bug, before choosing between a fix and a known-issues entry. Here it is verbatim. The examples and the &lt;code&gt;docs/superpowers/&lt;/code&gt; paths are from my repository; swap in your own spec directory.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;checking-the-fence&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Date a bug before fixing it — Chesterton&amp;#39;s fence. Use when you have found code that looks wrong and are about to change it, when you are about to delete something that looks dead, or when another skill needs the fence check.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gh"&gt;# Checking the Fence&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;A bug you found is a &lt;span class="gs"&gt;**fence**&lt;/span&gt; across a field. Before you take it down, find who put it there. Here that is written down twice — in &lt;span class="sb"&gt;`git log`&lt;/span&gt; and in &lt;span class="sb"&gt;`docs/superpowers/`&lt;/span&gt; — and is usually decisive in a minute.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Two verdicts. They are the whole skill:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;-&lt;/span&gt; **Fence** — someone decided this. Leave it, and record an ADR if the next reader will reach for the same fix you just did.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;-&lt;/span&gt; **Drift** — nobody decided it. The code predates the thing it is wrong about, or an author was never told which files the job touched. Fix it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Fences here are real, not hypothetical: &lt;span class="sb"&gt;`docs/adr/0002`&lt;/span&gt; exists only to stop a reader from deleting emoji handling that has no inbound traffic and looks dead.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;## Steps&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Stop at the first step that returns a verdict.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 1. Date the code&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log --oneline -S&amp;#39;&amp;lt;symbol&amp;gt;&amp;#39; --all -- &amp;lt;path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log -1 --format=%B &amp;lt;sha&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Commit messages here carry the rule the author meant to encode, and the rule is often the thing the bug breaks. &lt;span class="sb"&gt;`_PARSER_PRIORITY`&lt;/span&gt;&amp;#39;s own commit says &lt;span class="ge"&gt;*&amp;#34;process deterministic parsers before PDFs/images&amp;#34;*&lt;/span&gt; — while the table shipped the cheapest format last.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you can name the commit and quote the rule.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 2. Date what it is wrong about&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;A bug is a relationship: a table missing a format, a copy missing an entry, a switch missing a branch. Date the other side too.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Code cannot have decided about something that did not exist yet. &lt;span class="sb"&gt;`_PARSER_PRIORITY`&lt;/span&gt; (2026-04-27) predates isdoc (2026-06-01) by five weeks.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you have both dates. Predates → &lt;span class="gs"&gt;**drift**&lt;/span&gt;. Stop.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 3. Read the spec&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -rli &amp;#39;&amp;lt;feature&amp;gt;&amp;#39; docs/superpowers/specs docs/superpowers/plans&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Read the &lt;span class="gs"&gt;**Non-goals**&lt;/span&gt; first — that is where a deliberate gap is named. A spec that scopes the gap out is a &lt;span class="gs"&gt;**fence**&lt;/span&gt;. A spec whose own words the bug contradicts is &lt;span class="gs"&gt;**drift**&lt;/span&gt;: the ISDOC spec calls it &lt;span class="ge"&gt;*&amp;#34;a deterministic fast path before Claude&amp;#34;*&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you have quoted the line that scopes it out, or established that no spec names it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 4. Read the blast pattern&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show --stat &amp;lt;sha&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Which files that commit touched, against which it needed to. An author who updated three of five consumers was not deciding about the other two — nothing told them. The isdoc and docx commits landed a day apart and touched different subsets.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you can say whether the author addressed the site at all. Never addressed → &lt;span class="gs"&gt;**drift**&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 5. Was it born wrong?&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;For a forked copy, compare it against its source &lt;span class="gs"&gt;**on the day it was copied**&lt;/span&gt;, not today.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show &amp;lt;sha&amp;gt;:&amp;lt;source-path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sb"&gt;`coverage_report.py`&lt;/span&gt; shipped without &lt;span class="sb"&gt;`.json`&lt;/span&gt; on 2026-04-24, and the map it was copied from carried &lt;span class="sb"&gt;`.json`&lt;/span&gt; that same day. Born forked, then rotted twice more.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you know whether the gap is drift-over-time or a bad copy. Either is &lt;span class="gs"&gt;**drift**&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;## Applying the verdict&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Drift**&lt;/span&gt; — fix it, and say in the commit message what you found: the dates, and that nothing decided it. That is what stops a reviewer from wondering whether you removed a fence.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Fence**&lt;/span&gt; — leave it. Offer an ADR when a future reader would reach for the same fix; that reader is usually the next architecture review, or you.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Neither**&lt;/span&gt; — the history is silent and no spec mentions it. Say so plainly and let the user decide; silence is not consent, and it is not a fence either.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Save it as &lt;code&gt;.claude/skills/checking-the-fence/SKILL.md&lt;/code&gt; in your repository, and tell the agent in &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt; to run it before it records a bug as a known issue.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A parked bug looks like a decision, so check whether anyone made one. If the code predates what it breaks, fix it now, and check the mechanism first: knowing nobody meant the bug doesn&amp;rsquo;t tell you what depends on it.&lt;/p&gt;&#10;&lt;p&gt;Next time your agent adds an &lt;code&gt;expectedFailure&lt;/code&gt; or a known-issues entry, ask it for the two dates first.&lt;/p&gt;&#10;</description></item><item><title>Every test mocked the helper, and except Exception swallowed its TypeError on every PDF</title><link>https://eghuro.cz/posts/mocked-helper-swallowed-typeerror/</link><pubDate>Thu, 16 Jul 2026 23:42:00 +0200</pubDate><guid>https://eghuro.cz/posts/mocked-helper-swallowed-typeerror/</guid><description>&lt;p&gt;For seven weeks, a document classifier never read a single PDF. This is the helper that was supposed to give the model some text (trimmed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_extract_excerpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;First-page text for PDFs, first ~8 kB for everything else.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.pdf&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;EXCERPT_BYTES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;extract_pdf&lt;/code&gt; doesn&amp;rsquo;t return a list of pages. It returns a dataclass, so every call raised this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TypeError: &amp;#39;PDFExtraction&amp;#39; object is not subscriptable&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;except&lt;/code&gt; caught it and returned &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;. The prompt then held the filename and an empty excerpt, and the model answered anyway: a document type and a confidence score, for every PDF it was asked about.&lt;/p&gt;&#10;&lt;p&gt;If a helper of yours has a catch-all &lt;code&gt;except&lt;/code&gt; and your tests patch that helper, check whether anything ever runs it for real.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;A web upload went through a small Python script that sorted the file into one of the document kinds declared for an accounting period (received invoices, bank statements, and so on). With two or more possible kinds, it built a prompt from the filename plus the start of the document and asked a language model, which answered with JSON like &lt;code&gt;{&amp;quot;kind&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;confidence&amp;quot;: 0.9}&lt;/code&gt;. The PDF text came from an in-house &lt;code&gt;extract_pdf()&lt;/code&gt; built on pdfplumber. Tests used the standard library&amp;rsquo;s &lt;code&gt;unittest&lt;/code&gt; and &lt;code&gt;unittest.mock&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The helper was wrong from the day it was written. &lt;code&gt;extract_pdf&lt;/code&gt; had returned a &lt;code&gt;PDFExtraction&lt;/code&gt; dataclass with a &lt;code&gt;pages&lt;/code&gt; list since its first commit, six weeks earlier, and its docstring said so. The &lt;code&gt;if pages&lt;/code&gt; check didn&amp;rsquo;t catch the mistake: a dataclass without &lt;code&gt;__len__&lt;/code&gt; is always truthy, so the check passed and &lt;code&gt;pages[0]&lt;/code&gt; raised.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;except&lt;/code&gt; was there so that a corrupt or encrypted upload wouldn&amp;rsquo;t break classification. It caught the &lt;code&gt;TypeError&lt;/code&gt; just as readily and returned &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;, which is also the documented answer for an image upload with no text to read.&lt;/p&gt;&#10;&lt;p&gt;And every test patched the helper out. The tests that reached the model looked like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;scripts.classify_upload.invoke_ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;scripts.classify_upload._extract_excerpt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Invoice 2026-001 ...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;received_invoices&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;confidence&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;classify_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/tmp/x.pdf&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;issued_invoices&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;received_invoices&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;claude&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;They covered the prompt plumbing and the JSON parsing, and none of them ever called &lt;code&gt;_extract_excerpt&lt;/code&gt;. The implementation plan that specified the helper had the same broken line, and it specified these tests, mocks included. The code, the plan, and the tests agreed with each other, and none of them had been checked against &lt;code&gt;extract_pdf&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The model hid the rest. A filename like &lt;code&gt;invoice-0423.pdf&lt;/code&gt; can be enough for a right guess, and a wrong guess came back with a confidence score like any other, so nothing in the output said the excerpt was empty.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;The fix reads the first page, as the docstring promised:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;extraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;first_page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;first_page&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;EXCERPT_BYTES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The new test class calls the real function on a real PDF:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RealPdfExcerptTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&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="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tearDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_a_pdf_yields_its_first_page_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;invoice.pdf&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_write_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;INVOICE 2026-001 Supplier ACME&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;INVOICE 2026-001&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_extract_excerpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_a_corrupt_pdf_yields_no_signal_rather_than_raising&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;broken.pdf&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;%PDF-1.4&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;not actually a pdf at all&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_extract_excerpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;_write_pdf&lt;/code&gt; is an existing test helper that writes a one-page PDF with a text layer. Before committing, I ran the first test against the original code to confirm it fails there. The &lt;code&gt;except&lt;/code&gt; stayed, because corrupt uploads are real. The second test now says what it&amp;rsquo;s for.&lt;/p&gt;&#10;&lt;p&gt;A day later, the same helper had a second route to the same result. It kept its own list of text suffixes, so Word, Excel, and XML e-invoice uploads fell through to &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt; and were classified by filename too. Two of those formats joined the pipeline a week after the list was written, and nobody updated it. The helper now asks the pipeline&amp;rsquo;s format registry instead.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;A mock replaces the code it patches with your assumptions about that code. When every test mocks a function, its tests check only those assumptions, and the function itself is never run. A catch-all &lt;code&gt;except&lt;/code&gt; inside the function then removes the last signal you&amp;rsquo;d get in production: no traceback, no failed request, just a default value that looks legitimate.&lt;/p&gt;&#10;&lt;p&gt;A language model downstream makes this worse. A parser fed an empty string usually fails loudly, while a model returns its best guess in the usual format, confidence field included, whatever the input.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Mock the expensive or external dependency (here, the model) and let the function under test run on a real input. If it has a catch-all &lt;code&gt;except&lt;/code&gt;, give it a test with an input that should succeed, so a bug can&amp;rsquo;t hide behind the &lt;code&gt;except&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Grep your tests for &lt;code&gt;patch(&lt;/code&gt; targets that point at your own module&amp;rsquo;s private helpers, and for each one, find a test that calls the helper unmocked.&lt;/p&gt;&#10;</description></item><item><title>Sandbox-blocked tests called "environmental", then committed</title><link>https://eghuro.cz/posts/sandbox-blocked-tests-called-environmental/</link><pubDate>Mon, 13 Jul 2026 15:38:00 +0200</pubDate><guid>https://eghuro.cz/posts/sandbox-blocked-tests-called-environmental/</guid><description>&lt;p&gt;My coding agent finished a feature, ran the full test suite, and reported:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Full suite: 135 passed, 97 skipped; 22 DB suites failed due to no container runtime, plus sandboxed stub bind. Commit only handoff implementation files.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then it committed. I ran &lt;code&gt;pnpm test&lt;/code&gt; myself and got three real failures. The production build it had also skipped was broken too, and nobody found out for two months.&lt;/p&gt;&#10;&lt;p&gt;If your agent&amp;rsquo;s test run shows &lt;code&gt;Error: Could not find a working container runtime strategy&lt;/code&gt; or &lt;code&gt;Error: listen EPERM: operation not permitted 0.0.0.0:8080&lt;/code&gt;, those tests never ran, and a summary that counts them as &amp;ldquo;blocked&amp;rdquo; tells you nothing about the code.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Codex CLI on macOS, with its default sandbox&lt;/li&gt;&#10;&lt;li&gt;A pnpm monorepo: a Next.js 15.5.19 app plus a few packages&lt;/li&gt;&#10;&lt;li&gt;Vitest 2.1.9, with Testcontainers 10.28.0 starting Postgres for the database tests and a local stub of an external service listening on port 8080&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The agent split the feature into five slices and gave each to a parallel subagent. Each subagent ran its own focused tests, and the orchestrator ran the full suite and the build at the end.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The sandbox blocks more than writes outside the project. Here it blocked three things the checks needed:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Testcontainers couldn&amp;rsquo;t reach Docker: &lt;code&gt;Could not find a working container runtime strategy&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The service stub couldn&amp;rsquo;t bind its port: &lt;code&gt;listen EPERM: operation not permitted 0.0.0.0:8080&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;next build&lt;/code&gt; couldn&amp;rsquo;t write its compiler cache: &lt;code&gt;EPERM&lt;/code&gt; writing &lt;code&gt;~/Library/Caches/next-swc&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Every database suite failed in &lt;code&gt;beforeAll&lt;/code&gt;, and Vitest reported the tests inside them as skipped. The agent&amp;rsquo;s final report was accurate:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Typecheck pass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Focused tests pass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Full: 135 passed; DB suites blocked by no Docker&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Build blocked by Next SWC cache permissions&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every line of that is true. But the suite has 232 tests, and 135 + 97 = 232: over 40 percent of it never ran, including every test that touched the database, where this feature did most of its work.&lt;/p&gt;&#10;&lt;p&gt;On the host, the same suite printed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Test Files 2 failed | 63 passed (65)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Tests 3 failed | 229 passed (232)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One new test forgot to start the service stub, so its client found no endpoint configured. Two older tests broke because the feature added an ownership check and their fixtures sent requests without a logged-in user (&lt;code&gt;Error: authentication required&lt;/code&gt;). The agent fixed all three in two minutes once it saw them.&lt;/p&gt;&#10;&lt;p&gt;The build was worse. The commit re-exported a server action from another &lt;code&gt;&amp;quot;use server&amp;quot;&lt;/code&gt; file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;use server&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;finalizeArtifact&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kr"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;../../reports/actions.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;tsc&lt;/code&gt; accepts this, and so do the tests. Only &lt;code&gt;next build&lt;/code&gt; rejects it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Failed to compile.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./app/reports/[id]/actions.ts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error: x Only async functions are allowed to be exported in a &amp;#34;use server&amp;#34; file.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two months later, a different session ran a production build, hit this error, and deleted the line.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-same-sandbox-the-same-day"&gt;The same sandbox, the same day &lt;/h2&gt;&#10;&lt;p&gt;Half an hour before the bad commit, another session in the same repository hit the same two errors. At first it called them environmental too. Then it decided:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Full suite blocked by sandbox: Testcontainers cannot see Docker; stub bind gets `EPERM :8080`. Since Docker is reportedly running, retry full suite with host access.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two minutes later it reported &amp;ldquo;Host rerun green: 60 files, 222 tests&amp;rdquo;, and only then did it commit. The agent that fixed my three failures did the same: it reran the focused tests with host approval before committing the fix. Both sessions knew how to leave the sandbox; nothing required them to before a commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Treat &amp;ldquo;blocked&amp;rdquo; as &amp;ldquo;not run&amp;rdquo;, and make &amp;ldquo;not run&amp;rdquo; stop the commit.&lt;/p&gt;&#10;&lt;p&gt;In Codex, a command runs outside the sandbox when the agent requests &lt;code&gt;sandbox_permissions: &amp;quot;require_escalated&amp;quot;&lt;/code&gt; and you approve it. Put the rule in the instructions file the agent reads every session (&lt;code&gt;AGENTS.md&lt;/code&gt;, or &lt;code&gt;CLAUDE.md&lt;/code&gt; for Claude Code):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;A check that the sandbox blocked has not passed. Before committing, rerun every blocked test suite and build with host access (&lt;span class="sb"&gt;`sandbox_permissions: &amp;#34;require_escalated&amp;#34;`&lt;/span&gt;).&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;If that isn&amp;#39;t possible, don&amp;#39;t commit: report what didn&amp;#39;t run.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then read the agent&amp;rsquo;s numbers yourself. If passed plus skipped equals the total, and you never skip tests on purpose, every skipped test is one that didn&amp;rsquo;t run. If you have CI, have it run the full suite and the build before a merge.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-it-happens"&gt;Why it happens &lt;/h2&gt;&#10;&lt;p&gt;The agent&amp;rsquo;s memory for this project already had a rule: &amp;ldquo;separate passed static or focused checks from runtime suites blocked by the environment, and reserve the final green claim for integrated verification.&amp;rdquo; The agent followed that rule to the letter: it never claimed green and listed exactly what had been blocked. Then it committed, because nothing in its instructions tied the commit to the green claim.&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;Blocked by the environment&amp;rdquo; reads like an infrastructure problem that someone else will handle, so it ends up as a footnote in the report while the commit goes ahead. The rule made the report honest. It didn&amp;rsquo;t make anyone run the tests.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;When a sandbox blocks a test or a build, nothing was verified. An agent that reports this honestly will still commit, unless its instructions say that a blocked check stops the commit.&lt;/p&gt;&#10;&lt;p&gt;Look at your agent&amp;rsquo;s last commit: if its summary says a suite or a build was &amp;ldquo;blocked&amp;rdquo;, rerun them on the host today.&lt;/p&gt;&#10;</description></item><item><title>ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY: your coding agent's pnpm 11 is not your pnpm 10</title><link>https://eghuro.cz/posts/pnpm-11-agent-runtime-no-tty/</link><pubDate>Mon, 06 Jul 2026 15:04:00 +0200</pubDate><guid>https://eghuro.cz/posts/pnpm-11-agent-runtime-no-tty/</guid><description>&lt;p&gt;A coding agent ran &lt;code&gt;pnpm test&lt;/code&gt; in my repo and got this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Scope: all 6 workspace projects&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY] Aborted removal of modules directory due to no TTY&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;If you are running pnpm in CI, set the CI environment variable to &amp;#34;true&amp;#34;, or set &amp;#34;confirmModulesPurge&amp;#34; to &amp;#34;false&amp;#34;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The stack trace ended in &lt;code&gt;runDepsStatusCheck (.../node_modules/.pnpm/pnpm@11.7.0/...)&lt;/code&gt;. My &lt;code&gt;package.json&lt;/code&gt; says &lt;code&gt;&amp;quot;packageManager&amp;quot;: &amp;quot;pnpm@10.33.0&amp;quot;&lt;/code&gt;, and &lt;code&gt;pnpm -v&lt;/code&gt; in my terminal prints &lt;code&gt;10.33.0&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If you see this error only inside an agent, the agent is running its own pnpm, and following the hint makes things worse.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS, pnpm 10.33.0 through Corepack, a pnpm monorepo with a Next.js 15 app&lt;/li&gt;&#10;&lt;li&gt;The Codex desktop app, which ships its own Node 24 and pnpm under &lt;code&gt;~/.cache/codex-runtimes/codex-primary-runtime/dependencies/&lt;/code&gt;: pnpm 11.7.0 in July, 11.25.0 in October&lt;/li&gt;&#10;&lt;li&gt;The agent runs &lt;code&gt;pnpm test&lt;/code&gt; without a TTY&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The agent followed the hint and reran with &lt;code&gt;CI=true pnpm test&lt;/code&gt;. pnpm stopped asking and did what it had wanted to do from the start:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Recreating /Users/me/project/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Progress: resolved 378, reused 0, downloaded 137, added 12&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[WARN] GET https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz error (ERR_PNPM_ENOSPC). Will retry in 10 seconds. 2 retries left.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[WARN] GET https://registry.npmjs.org/next/-/next-15.5.19.tgz error (ERR_PNPM_ENOSPC). Will retry in 10 seconds. 2 retries left.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Progress: resolved 378, reused 0, downloaded 377, added 377, done&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cpu-features@0.0.10, esbuild@0.21.5, esbuild@0.28.1, protobufjs@7.6.4, sharp@0.34.5, ssh2@1.17.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It deleted &lt;code&gt;node_modules&lt;/code&gt; and downloaded every package again (&lt;code&gt;reused 0&lt;/code&gt;) into a second store, and the disk ran out on the way. It left an untracked &lt;code&gt;.pnpm-store/&lt;/code&gt; in the repo and wrote placeholders into the tracked &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;allowBuilds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cpu-features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;esbuild&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;protobufjs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ssh2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No tests ran. After that, even &lt;code&gt;pnpm exec tsc --noEmit&lt;/code&gt; failed with the same NO_TTY error, so the agent switched to &lt;code&gt;./node_modules/.bin/tsc&lt;/code&gt; and &lt;code&gt;./node_modules/.bin/vitest&lt;/code&gt;, reverted the YAML, deleted &lt;code&gt;.pnpm-store/&lt;/code&gt; and committed with only the focused tests green.&lt;/p&gt;&#10;&lt;p&gt;Ten weeks later, &lt;code&gt;next build&lt;/code&gt; failed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⚠ Attempted to load @next/swc-darwin-arm64, but it was not installed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⨯ Failed to load SWC binary for darwin/arm64, see more info here: https://nextjs.org/docs/messages/failed-loading-swc&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The July install had ended one package short (378 resolved, 377 added), and the SWC binary was one of the two downloads that hit &lt;code&gt;ENOSPC&lt;/code&gt;. A later agent session &amp;ldquo;fixed&amp;rdquo; the build by installing the binary into &lt;code&gt;/tmp&lt;/code&gt; and symlinking it into &lt;code&gt;node_modules&lt;/code&gt;, a fix that lasts until macOS cleans &lt;code&gt;/tmp&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;First, ask the agent&amp;rsquo;s shell which pnpm it gets:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;command&lt;/span&gt; -v pnpm&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pnpm -v&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then make the agent use your version. Corepack reads &lt;code&gt;packageManager&lt;/code&gt;, whatever is first on &lt;code&gt;PATH&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;corepack pnpm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="c1"&gt;# runs pnpm 10.33.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write that into &lt;code&gt;AGENTS.md&lt;/code&gt; (or your agent&amp;rsquo;s equivalent), along with one rule: if pnpm wants to remove &lt;code&gt;node_modules&lt;/code&gt;, stop and report.&lt;/p&gt;&#10;&lt;p&gt;To make the bundled pnpm harmless as well, turn off the automatic install in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;verifyDepsBeforeRun&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is the pnpm 10 default, so nothing changes for you. I checked it with the bundled pnpm 11.25.0: with the setting, it runs the script; without it, it installs first.&lt;/p&gt;&#10;&lt;p&gt;If you already followed the hint, clean up with your own pnpm:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git checkout -- pnpm-workspace.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf .pnpm-store&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;corepack pnpm install --frozen-lockfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run that in a terminal. If pnpm asks to remove &lt;code&gt;node_modules&lt;/code&gt;, say yes; this time it rebuilds against your own store.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;Three pnpm 11 changes line up:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;code&gt;verify-deps-before-run&lt;/code&gt; defaults to &lt;code&gt;install&lt;/code&gt; (pnpm 10: &lt;code&gt;false&lt;/code&gt;). &lt;code&gt;pnpm test&lt;/code&gt; and &lt;code&gt;pnpm exec&lt;/code&gt; check &lt;code&gt;node_modules&lt;/code&gt; first and install if anything is off.&lt;/li&gt;&#10;&lt;li&gt;pnpm 11 uses store &lt;code&gt;v11&lt;/code&gt;; pnpm 10 uses &lt;code&gt;v10&lt;/code&gt;. &lt;code&gt;node_modules/.modules.yaml&lt;/code&gt; records the store it was linked from, so to pnpm 11 a working pnpm 10 install looks incompatible and has to be purged. &lt;code&gt;confirmModulesPurge&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;; a purge without a TTY aborts, and &lt;code&gt;CI=true&lt;/code&gt; drops the confirmation but not the purge.&lt;/li&gt;&#10;&lt;li&gt;Ignored build scripts are an error, and pnpm writes the &lt;code&gt;allowBuilds&lt;/code&gt; placeholders for you to fill in.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;On my Mac, the two stores now sit side by side in &lt;code&gt;~/Library/pnpm/store/&lt;/code&gt;: 851 MB in &lt;code&gt;v10&lt;/code&gt;, 588 MB in &lt;code&gt;v11&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Why didn&amp;rsquo;t &lt;code&gt;packageManager&lt;/code&gt; catch it? pnpm 11 does honor it: run the bundled binary directly and &lt;code&gt;pnpm -v&lt;/code&gt; prints &lt;code&gt;10.33.0&lt;/code&gt;, because it switches to the pinned version. The wrapper in the October runtime turns that off:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;dirname &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;pnpm_config_pm_on_fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ignore&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../../node/bin/node&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../../node/node_modules/pnpm/bin/pnpm.mjs&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The environment variable beats anything in the repo. With &lt;code&gt;pmOnFail: error&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, or &lt;code&gt;devEngines.packageManager&lt;/code&gt; with &lt;code&gt;&amp;quot;onFail&amp;quot;: &amp;quot;error&amp;quot;&lt;/code&gt;, the wrapper still runs 11.25.0.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An agent runtime can bring its own toolchain and switch off the check that would have caught it. When an agent reports &amp;ldquo;tests blocked by the environment&amp;rdquo;, find out which binary it ran before you believe the report.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;pnpm -v&lt;/code&gt; in your agent&amp;rsquo;s shell today and compare it with &lt;code&gt;packageManager&lt;/code&gt;.&lt;/p&gt;&#10;</description></item><item><title>TS2724 "has no exported member" in a git worktree: a symlinked node_modules typechecks the main checkout's source</title><link>https://eghuro.cz/posts/worktree-symlinked-node-modules-stale-workspace-package/</link><pubDate>Mon, 22 Jun 2026 19:21:00 +0200</pubDate><guid>https://eghuro.cz/posts/worktree-symlinked-node-modules-stale-workspace-package/</guid><description>&lt;p&gt;A coding agent cherry-picked a subagent&amp;rsquo;s commit into an integration worktree, ran &lt;code&gt;pnpm typecheck&lt;/code&gt; and got:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apps/web/lib/uploads.ts(3,59): error TS2724: &amp;#39;&amp;#34;@app/persistence&amp;#34;&amp;#39; has no exported member named &amp;#39;recordRawUpload&amp;#39;. Did you mean &amp;#39;recordUpload&amp;#39;?&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;recordRawUpload&lt;/code&gt; was right there, exported from &lt;code&gt;packages/persistence/src/index.ts&lt;/code&gt; in that worktree. If tsc can&amp;rsquo;t see an export that you can, and the worktree&amp;rsquo;s &lt;code&gt;node_modules&lt;/code&gt; is a symlink to another checkout, tsc is reading the other checkout&amp;rsquo;s source.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A pnpm 10 monorepo on macOS. The workspace packages &lt;code&gt;@app/*&lt;/code&gt; point &lt;code&gt;exports&lt;/code&gt; straight at &lt;code&gt;./src/index.ts&lt;/code&gt;, so there is no build step between packages.&lt;/li&gt;&#10;&lt;li&gt;An orchestrating agent that creates a git worktree, a branch, and a subagent per issue, and cherry-picks finished work into an integration worktree.&lt;/li&gt;&#10;&lt;li&gt;A full disk.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;Every worktree had its own install, and &lt;code&gt;du&lt;/code&gt; counted up to 513 MB of &lt;code&gt;node_modules&lt;/code&gt; in each. A dozen worktrees later, the disk was full:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Filesystem Size Used Avail Capacity iused ifree %iused Mounted on&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/disk3s5 460Gi 420Gi 116Mi 100% 6.6M 1.2M 85% /System/Volumes/Data&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To save space, the agent replaced the &lt;code&gt;node_modules&lt;/code&gt; of each new worktree (the root, &lt;code&gt;apps/web&lt;/code&gt; and every package) with a symlink to the main checkout&amp;rsquo;s copy:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ln -s ../project/node_modules ../project-integration/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ln -s ../../../project/apps/web/node_modules ../project-integration/apps/web/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first typecheck after that passed. Later, a change that added an export to &lt;code&gt;@app/persistence&lt;/code&gt; and used it in &lt;code&gt;apps/web&lt;/code&gt; failed with TS2724. &lt;code&gt;readlink&lt;/code&gt; shows why:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; readlink apps/web/node_modules/@app/persistence&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;../../../../packages/persistence&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;pnpm links workspace packages with relative symlinks. A relative link resolves from the directory it really sits in, and that directory is now in the main checkout. So &lt;code&gt;@app/persistence&lt;/code&gt; in the worktree was &lt;code&gt;/Users/me/project/packages/persistence&lt;/code&gt;, the main checkout&amp;rsquo;s source, which didn&amp;rsquo;t have the new function yet.&lt;/p&gt;&#10;&lt;p&gt;The typecheck that passed also validated the main checkout.&lt;/p&gt;&#10;&lt;p&gt;Two subagents in their own worktrees hit the same error. One deleted the links, reinstalled, and its typecheck passed. The other wrote &amp;ldquo;Validation blocked by worktree node_modules links targeting older shared package. Source diff reviewed; commit now.&amp;rdquo; and committed without a passing typecheck.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give each worktree its own install. pnpm keeps package files in a content-addressable store, so an offline install only links them into place:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm node_modules apps/web/node_modules packages/*/node_modules &lt;span class="c1"&gt;# the symlinks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pnpm install --offline --frozen-lockfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This took 3.6 seconds and reported &lt;code&gt;resolved 378, reused 378, downloaded 0&lt;/code&gt;. If the store is missing a package, &lt;code&gt;--offline&lt;/code&gt; fails; drop the flag.&lt;/p&gt;&#10;&lt;p&gt;Then check that every workspace package resolves inside the worktree. Run it with bash: zsh aborts on the first glob that matches nothing (&lt;code&gt;no matches found&lt;/code&gt;), and the agent&amp;rsquo;s own fix hit exactly that.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Print workspace packages that resolve outside this checkout.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt; -P&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; link in node_modules/@app/* */*/node_modules/@app/*&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;[&lt;/span&gt; -e &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;realpath &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$target&lt;/span&gt; in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No output means every workspace package comes from this worktree.&lt;/p&gt;&#10;&lt;p&gt;If you have to share &lt;code&gt;node_modules&lt;/code&gt;, share only the third-party packages. The agent&amp;rsquo;s fix was an overlay: a real &lt;code&gt;node_modules&lt;/code&gt; directory per package, with every entry symlinked to the main checkout except &lt;code&gt;@app&lt;/code&gt;, whose entries point at the worktree&amp;rsquo;s own packages. Copying the main checkout&amp;rsquo;s &lt;code&gt;@app&lt;/code&gt; directory with &lt;code&gt;cp -RP&lt;/code&gt; gives the same result, because the copied relative links resolve from their new place.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;TypeScript (with the default &lt;code&gt;preserveSymlinks: false&lt;/code&gt;) and Node both resolve a package to its real path. Third-party packages are the same files in every checkout that shares a lockfile, so following the link to the main checkout is harmless. Workspace packages are your source code, and that differs between checkouts by design. Once a branch changes its dependencies, the third-party half goes stale as well.&lt;/p&gt;&#10;&lt;p&gt;The disk savings were probably smaller than they looked. pnpm clones or hard-links files from its store, and &lt;code&gt;du&lt;/code&gt; counts those blocks again in every tree, so its per-worktree numbers overstate what each install cost. The disk was also full for other reasons, and nobody measured how much the links saved.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A worktree that borrows another checkout&amp;rsquo;s &lt;code&gt;node_modules&lt;/code&gt; gets that checkout&amp;rsquo;s workspace packages too, and typecheck and tests then run against the wrong code, passing or failing. Run &lt;code&gt;pnpm install --offline&lt;/code&gt; in every new worktree.&lt;/p&gt;&#10;&lt;p&gt;If your agents create worktrees, run the check above in one of them today.&lt;/p&gt;&#10;</description></item><item><title>Next.js "Cannot read properties of undefined (reading 'length')" in installChunk: two processes share .next</title><link>https://eghuro.cz/posts/nextjs-installchunk-shared-next-dir/</link><pubDate>Mon, 22 Jun 2026 13:01:00 +0200</pubDate><guid>https://eghuro.cz/posts/nextjs-installchunk-shared-next-dir/</guid><description>&lt;p&gt;I opened the dashboard of my Next.js app and got a 500 error:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; GET /app/dashboard 500 in 285ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⨯ TypeError: Cannot read properties of undefined (reading &amp;#39;length&amp;#39;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at &amp;lt;unknown&amp;gt; (.next/server/app/app/accounts/page.js:685:47)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at Object.&amp;lt;anonymous&amp;gt; (.next/server/app/app/accounts/page.js:688:3) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; page: &amp;#39;/app/accounts&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; GET /app/accounts 500 in 278ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the stack points into generated &lt;code&gt;.next/server/.../page.js&lt;/code&gt; and the full trace ends in &lt;code&gt;installChunk&lt;/code&gt; in &lt;code&gt;webpack-runtime.js&lt;/code&gt;, your code is fine. Another Next process has written into the &lt;code&gt;.next&lt;/code&gt; directory your dev server is reading from.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Next.js 15.5 with webpack in dev, in a pnpm monorepo (the app lives in &lt;code&gt;apps/web&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;pnpm dev&lt;/code&gt; running in my terminal on port 3000&lt;/li&gt;&#10;&lt;li&gt;A coding agent working in the same checkout, which checks its work with &lt;code&gt;pnpm build&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;I asked the agent to fix the 500. It did what anyone would do with &lt;code&gt;reading 'length'&lt;/code&gt;: it grepped the app for &lt;code&gt;.length&lt;/code&gt;. The accounts page didn&amp;rsquo;t touch a collection at all, so the agent followed the authenticated layout into the auth code and suspected an import there. Then it opened the generated &lt;code&gt;page.js&lt;/code&gt; at line 685 and found webpack glue, not application code.&lt;/p&gt;&#10;&lt;p&gt;To reproduce, the agent ran &lt;code&gt;pnpm build&lt;/code&gt;, which succeeded, and then started its own &lt;code&gt;next dev -p 3001&lt;/code&gt; next to mine. The first request to &lt;code&gt;/app/accounts&lt;/code&gt; returned 200. After a request to the dashboard, both routes returned 500, and this time the error page carried the whole stack:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TypeError: Cannot read properties of undefined (reading &amp;#39;length&amp;#39;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at installChunk (apps/web/.next/server/webpack-runtime.js:249:41)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at __webpack_require__.f.require (apps/web/.next/server/webpack-runtime.js:259:15)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at apps/web/.next/server/app/app/dashboard/page.js:718:47&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Line 249 is in webpack&amp;rsquo;s chunk loader:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;installChunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;moreModules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunkIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chunkIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;installedChunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chunkIds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;chunk&lt;/code&gt; is whatever &lt;code&gt;require(&amp;quot;./&amp;quot; + chunkFile)&lt;/code&gt; returned. &lt;code&gt;chunk.ids&lt;/code&gt; was undefined, so the file on disk was not the chunk this runtime expected. A &lt;code&gt;ps&lt;/code&gt; showed why: two &lt;code&gt;next-server&lt;/code&gt; processes, mine on 3000 and the agent&amp;rsquo;s on 3001, both compiling into &lt;code&gt;apps/web/.next&lt;/code&gt;. On top of that, the agent&amp;rsquo;s &lt;code&gt;next build&lt;/code&gt; had written its production output into the same directory a few minutes earlier.&lt;/p&gt;&#10;&lt;p&gt;Both of the usual ways to reproduce a bug, a fresh build and a second server, are ways to cause this one. Before you reproduce, look for other Next processes on the checkout:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ps -axo pid,ppid,command &lt;span class="p"&gt;|&lt;/span&gt; grep &lt;span class="s1"&gt;&amp;#39;[n]ext-server&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give &lt;code&gt;next dev&lt;/code&gt; its own output directory in &lt;code&gt;next.config.mjs&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// `next dev` and `next build` generate incompatible webpack chunk graphs.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Keep their output separate so a build cannot break a running dev server.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;distDir&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;development&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next-dev&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ignore it in &lt;code&gt;.gitignore&lt;/code&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-gitignore" data-lang="gitignore"&gt;.next/&#10;.next-dev/&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Add its generated types to &lt;code&gt;tsconfig.json&lt;/code&gt;, next to the &lt;code&gt;.next&lt;/code&gt; ones:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;include&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;next-env.d.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;**/*.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;**/*.tsx&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next/types/**/*.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next-dev/types/**/*.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The running dev server picked up the config change and rebuilt into &lt;code&gt;.next-dev&lt;/code&gt;. From then on, &lt;code&gt;next build&lt;/code&gt; writes to &lt;code&gt;.next&lt;/code&gt; and the dev server never reads it.&lt;/p&gt;&#10;&lt;p&gt;To recover without the config change, stop every Next process except one, delete &lt;code&gt;.next&lt;/code&gt; and restart &lt;code&gt;pnpm dev&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re on Next.js 16, you already have this: &lt;code&gt;next dev&lt;/code&gt; writes to &lt;code&gt;.next/dev&lt;/code&gt;, so &lt;a&#10; href="https://nextjs.org/docs/app/api-reference/cli/next"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;dev and build can run concurrently&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-follow-on-trap-next-envdts"&gt;The follow-on trap: &lt;code&gt;next-env.d.ts&lt;/code&gt; &lt;/h2&gt;&#10;&lt;p&gt;Next rewrites &lt;code&gt;next-env.d.ts&lt;/code&gt; on every dev start and every build, and the file names the types of whichever directory ran last:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;/// &amp;lt;reference path=&amp;#34;./.next-dev/types/routes.d.ts&amp;#34; /&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After every &lt;code&gt;next build&lt;/code&gt;, it says &lt;code&gt;./.next/types/routes.d.ts&lt;/code&gt; instead. The file was tracked, so it showed up as modified in later sessions, and the agents kept &amp;ldquo;fixing&amp;rdquo; it with &lt;code&gt;git show HEAD:apps/web/next-env.d.ts &amp;gt; apps/web/next-env.d.ts&lt;/code&gt;. Do what the &lt;code&gt;.gitignore&lt;/code&gt; from &lt;code&gt;create-next-app&lt;/code&gt; does and ignore the file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;next-env.d.ts&amp;#39;&lt;/span&gt; &amp;gt;&amp;gt; apps/web/.gitignore&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git rm --cached apps/web/next-env.d.ts&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;next dev&lt;/code&gt; and &lt;code&gt;next build&lt;/code&gt; both default to &lt;code&gt;.next&lt;/code&gt; in Next.js 15, and they write different chunk graphs into it. A second &lt;code&gt;next dev&lt;/code&gt; is no better: two webpack compilers write into one directory, and neither knows about the other. The runtime that is already loaded &lt;code&gt;require()&lt;/code&gt;s a chunk by the name from its own graph, gets a file the other compilation wrote, and fails at &lt;code&gt;chunk.ids.length&lt;/code&gt;. The error says &lt;code&gt;length&lt;/code&gt;, so you go looking for an array in your code.&lt;/p&gt;&#10;&lt;p&gt;Coding agents check their work with &lt;code&gt;next build&lt;/code&gt;, usually while your dev server is still running, so you&amp;rsquo;ll hit this more often than you&amp;rsquo;d think.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An error from inside &lt;code&gt;installChunk&lt;/code&gt; points at the build directory, so look for a second Next process before you read your own code. Never let two Next processes share a &lt;code&gt;distDir&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If an agent or a script runs &lt;code&gt;next build&lt;/code&gt; in the checkout where your dev server runs, give dev its own &lt;code&gt;distDir&lt;/code&gt; today.&lt;/p&gt;&#10;</description></item><item><title>Reference data published after the period: provisional values, then frozen observations</title><link>https://eghuro.cz/posts/reference-data-published-after-the-period/</link><pubDate>Sun, 21 Jun 2026 22:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/reference-data-published-after-the-period/</guid><description>&lt;p&gt;A tax engine converts foreign-currency trades into the reporting currency. The law allows two methods per tax year: the central bank&amp;rsquo;s daily rate for each transaction date, or a single annual rate per currency that the tax authority publishes. Conservative mode always uses the annual rate.&lt;/p&gt;&#10;&lt;p&gt;The annual rate for a year is published in the first months of the next year. Users also want in-year numbers, so for most of a calendar year, conservative mode needs a value that does not exist yet.&lt;/p&gt;&#10;&lt;p&gt;The same thing happens with any reference value fixed after the period it covers: annual average rates, inflation indices used for indexation, final settlement prices, revised statistics. This post covers the design that handled it, and the bug in the first version of its &amp;ldquo;immutable cache&amp;rdquo;.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-status-part-of-the-value"&gt;Make the status part of the value &lt;/h2&gt;&#10;&lt;p&gt;While the annual rate is unpublished, conservative mode computes on the daily rate as a proxy. Without a label, a reviewer who sees conservative mode using daily rates in September reports it as a bug. So every conversion carries its status:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Conversion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;: &lt;span class="kt"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;provisional&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;rateRefs&lt;/span&gt;: &lt;span class="kt"&gt;RateKey&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// daily proxy&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;: &lt;span class="kt"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;final&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;rateRefs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;RateKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// annual rate published&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;: &lt;span class="kt"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;frozen&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;frozenFrom&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;provisional&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;final&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;rateObservations&lt;/span&gt;: &lt;span class="kt"&gt;RateObservation&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// pinned at handoff&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;: &lt;span class="kt"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;missing-rate&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;needed&lt;/span&gt;: &lt;span class="kt"&gt;RateKey&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The dashboard shows a provisional badge next to each figure computed that way.&lt;/p&gt;&#10;&lt;p&gt;Publication is not a stored flag. The annual rate counts as published when its rate record exists:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;annual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;readRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annualRateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taxYear&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;annual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;: &lt;span class="kt"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;final&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rateRefs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;annualKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;requestAnnualRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annualKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// enqueue a fetch job, deduplicated per key&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the provisional proxy&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An open assessment always reads through this function. Once the fetch job stores the annual rate, the next read returns &lt;code&gt;final&lt;/code&gt;. No &amp;ldquo;rate published&amp;rdquo; boolean can disagree with the data.&lt;/p&gt;&#10;&lt;p&gt;While the case is open, everything recomputes live. When it is handed to an advisor, the conversions freeze. That gives two rules: open assessments track the newest data, and handed-off assessments stay reproducible forever.&lt;/p&gt;&#10;&#10;&lt;h2 id="immutable-keyed-by-the-rates-identity-cannot-store-a-correction"&gt;&amp;ldquo;Immutable, keyed by the rate&amp;rsquo;s identity&amp;rdquo; cannot store a correction &lt;/h2&gt;&#10;&lt;p&gt;The first schema followed the design doc literally: a rate is a historical fact, so store it once and never refetch.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fetched_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;primary&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;grant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- no UPDATE, no DELETE&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- writes: insert ... on conflict do nothing&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The same design doc also said: &amp;ldquo;a correction is a new record, not an overwrite.&amp;rdquo; The primary key makes that impossible. A provider that corrects a published value sends a second row with the same key, and &lt;code&gt;on conflict do nothing&lt;/code&gt; drops it without an error. The cache stays immutable by losing the corrected value.&lt;/p&gt;&#10;&lt;p&gt;The second migration separated the two requirements. Observations are append-only, and a small pointer table says which observation is current:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_observation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;primary&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fetched_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_canonical&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;observation_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;references&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_observation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;primary&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;view&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_canonical&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;join&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rate_observation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;observation_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A &lt;code&gt;security definer&lt;/code&gt; function performs every write. It takes a per-key advisory lock. A retry with an equal value returns the existing observation. A different value inserts a new observation and moves the pointer. Open assessments read the &lt;code&gt;rate&lt;/code&gt; view and pick up the correction. A frozen assessment stores the observation IDs it used, one per conversion leg, and never reads the pointer again. A currency that the central bank doesn&amp;rsquo;t list converts through a pivot currency, so its conversion pins two observations.&lt;/p&gt;&#10;&#10;&lt;h2 id="three-postgres-gotchas-from-making-it-immutable"&gt;Three Postgres gotchas from making it immutable &lt;/h2&gt;&#10;&lt;p&gt;The first implementation of the freeze broke 10 tests. One fix commit covered three defects:&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;No NUL in text.&lt;/strong&gt; The advisory lock key concatenated the key parts with &lt;code&gt;chr(0)&lt;/code&gt; as a separator. Postgres &lt;code&gt;text&lt;/code&gt; cannot contain a null byte, so every write raised an error. Use a separator that cannot appear in the parts and is legal in text, such as &lt;code&gt;chr(31)&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;perform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtextextended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;concat_ws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_rate_date&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;FOR SHARE&lt;/code&gt; needs &lt;code&gt;UPDATE&lt;/code&gt; privilege.&lt;/strong&gt; At handoff, the freeze must not race a correction that moves the pointer. The first version locked the pointer row with &lt;code&gt;select ... for share&lt;/code&gt;. In Postgres, every row-locking clause requires &lt;code&gt;UPDATE&lt;/code&gt; privilege on the table, and the app role has none, on purpose. The fix takes the same advisory lock the writer takes, so the advisory lock becomes the single point of serialization. When one handoff freezes many conversions, it acquires the locks in a fixed order (sorted by conversion id), so two concurrent handoffs cannot deadlock.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;node-postgres turns &lt;code&gt;date&lt;/code&gt; into a shifted &lt;code&gt;Date&lt;/code&gt;.&lt;/strong&gt; By default, &lt;code&gt;pg&lt;/code&gt; parses a &lt;code&gt;date&lt;/code&gt; column (OID 1082) into a JavaScript &lt;code&gt;Date&lt;/code&gt; at local midnight. Serialized back as UTC in a timezone east of Greenwich, &lt;code&gt;2026-03-02&lt;/code&gt; becomes &lt;code&gt;2026-03-01T23:00:00.000Z&lt;/code&gt;, the previous day. The schema types already said &lt;code&gt;string&lt;/code&gt;. Keep the literal:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;types&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kr"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pg&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setTypeParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1082&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// &amp;#39;YYYY-MM-DD&amp;#39; stays a string; timestamptz unaffected&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="two-smaller-rules"&gt;Two smaller rules &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Exact decimals end to end.&lt;/strong&gt; The central bank quotes some currencies per 100 units. The parser divides the listed rate by the quoted quantity in exact decimal arithmetic, and rejects a result that would not terminate rather than rounding it. No rate passes through an IEEE float.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Crypto is an asset, not a currency.&lt;/strong&gt; The coding agent&amp;rsquo;s first plan gave crypto its own rate subsystem. I corrected that during the design session: crypto is priced through its trading pair (BTC/EUR, ETH/USD), like a stock, and the pair&amp;rsquo;s quote currency converts through an ordinary rate. Only a crypto-to-crypto swap needs a separate market valuation, and that is asset valuation, not currency conversion.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;When a reference value is published after the period it describes, put the status in the value (provisional, final, frozen) so that a proxy cannot pass for the real number. Let the existence of the record mean &amp;ldquo;published&amp;rdquo;. Treat &amp;ldquo;immutable&amp;rdquo; and &amp;ldquo;correctable&amp;rdquo; as two tables, not one primary key: append observations, point to the current one, and let frozen results pin observation IDs.&lt;/p&gt;&#10;&lt;p&gt;If your cache is immutable through &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, send it a corrected value for an existing key and check whether anything records that the value changed.&lt;/p&gt;&#10;</description></item><item><title>Before building a reference catalog, check whether the attribute reaches the computation</title><link>https://eghuro.cz/posts/check-the-attribute-reaches-the-computation/</link><pubDate>Sun, 21 Jun 2026 21:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/check-the-attribute-reaches-the-computation/</guid><description>&lt;p&gt;A tax engine for investment accounts has to put every instrument into a category, because the category decides the rules. A real share sold after the holding period can be exempt. A CFD on the same stock is a derivative and is taxed no matter how long you hold it. Brokers don&amp;rsquo;t make this easy. One reports CFDs under &amp;ldquo;stocks&amp;rdquo;. A position can be a fractional share or a whole one. An ETF&amp;rsquo;s ISIN doesn&amp;rsquo;t say whether the fund pays out dividends or reinvests them.&lt;/p&gt;&#10;&lt;p&gt;During the design session, the coding agent recommended a central instrument catalog: ISIN → {category, asset class, accumulating/distributing, derivative, fractional, …}, seeded from a market data provider and maintained over time. Per-broker adapters would propose a category and reconcile it against the catalog, and anything unresolved would go to a review queue. It argued that the accumulating/distributing flag in particular had to come from the catalog.&lt;/p&gt;&#10;&lt;p&gt;I rejected it in two sentences:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;If we get the dividend, we have to deal with it. If the fund gets the dividend, it&amp;rsquo;s non-taxable for the individual.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&#10;&lt;h2 id="the-attribute-decides-whether-an-event-exists"&gt;The attribute decides whether an event exists &lt;/h2&gt;&#10;&lt;p&gt;A distributing fund pays the investor a dividend, and the broker reports it as a dividend event, which is taxed. An accumulating fund keeps the dividend inside the fund. The investor receives nothing, and the broker reports nothing, so nothing is taxed until the units are sold, and the sale is an ordinary sell event.&lt;/p&gt;&#10;&lt;p&gt;The flag the catalog was meant to supply only decides whether an event exists, and the transaction ledger already records whether one did. Removing the flag from the catalog changes no input to the engine. It was also the catalog&amp;rsquo;s main justification.&lt;/p&gt;&#10;&lt;p&gt;The reusable check: for every attribute you are about to maintain as reference data, flip its value and ask which computation input changes.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Attribute&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;What changes if it flips&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Already in the events?&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Source&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Accumulating / distributing&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Whether a dividend event exists&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Yes: the event is present or absent&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;None needed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;CFD / real share&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The rules applied to the same sell event&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The broker&amp;rsquo;s own export (instrument type, leverage)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Fractional / whole share&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Whether an exemption is arguable&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The broker&amp;rsquo;s idiom&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Crypto / token / derivative on crypto&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Which rules and caps apply&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The broker&amp;rsquo;s idiom&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The remaining attributes do change the computation. All of them can be read from the broker that produced the record, so classification belongs in the per-broker adapter. The adapter already parses that broker&amp;rsquo;s export and knows its quirks. A global catalog would only reconcile against them afterward.&lt;/p&gt;&#10;&#10;&lt;h2 id="classify-where-the-source-idiom-lives-and-refuse-to-guess"&gt;Classify where the source idiom lives, and refuse to guess &lt;/h2&gt;&#10;&lt;p&gt;The adapter for the broker that labels CFDs as stocks classifies by the type and leverage columns of its own export:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;leverage&lt;/span&gt;: &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;explanation&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/cfd|contract for difference/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;derivative&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leverage&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/x$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;kind&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;explanation&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Type or Leverage is missing or invalid.&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&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;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;crypto&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;crypto&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;explanation&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Crypto Type conflicts with leveraged position (&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leverage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;).`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/(stock|etf|real|cfd|contract)/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;derivative&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/(stock|etf|real|share|security)/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;security&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;explanation&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Type &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt; and Leverage &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leverage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt; cannot be classified.`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No default branch picks the most likely category. A row the rules cannot classify becomes a review item (&lt;code&gt;AMBIGUOUS_CATEGORY&lt;/code&gt; with the raw row reference) and stays out of the tax computation. A misclassified instrument produces a wrong filing with no warning sign, which is worse than an incomplete one.&lt;/p&gt;&#10;&lt;p&gt;The ISIN and ticker stay on the record for cross-checking and audit, but nothing uses them to decide a category.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-dropping-the-catalog-removed"&gt;What dropping the catalog removed &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A licensed data feed and the work of keeping up with new listings, delistings, and corrections.&lt;/li&gt;&#10;&lt;li&gt;A reconciliation layer between adapter output and catalog values, plus the conflict UI that goes with it.&lt;/li&gt;&#10;&lt;li&gt;A global table keyed by ISIN that every user&amp;rsquo;s trades would look up. In this design, real instrument identity is visible only inside the adapter at ingestion, and the stored record carries a pseudonymous ID. That design is described in &lt;a&#10; href="https://eghuro.cz/posts/split-confidential-data-by-reader"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;When the server must compute on confidential data, split the data by who may read it&lt;/a&gt;&#10;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The cost moved into the adapters: classification is now only as correct as each adapter. Every adapter has its own test suite for its broker&amp;rsquo;s export format, and adding a broker means writing its classification rules and tests.&lt;/p&gt;&#10;&#10;&lt;h2 id="caveat-some-tax-systems-tax-events-that-never-happened"&gt;Caveat: some tax systems tax events that never happened &lt;/h2&gt;&#10;&lt;p&gt;The shortcut holds where tax follows money the investor actually receives. Some tax systems tax deemed income on fund holdings that paid nothing out. Germany&amp;rsquo;s &lt;em&gt;Vorabpauschale&lt;/em&gt;, UK reporting funds&amp;rsquo; excess reportable income and the US PFIC regime are examples. There, the accumulating flag produces an income line that no event carries, and you need the attribute from somewhere. That is exactly the case the flip test catches: the computation input changes even though no event changes.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Before you maintain reference data for an attribute, flip it and see which input of your computation changes. If the only effect is whether an event happens, and your event stream already records that, you don&amp;rsquo;t need the attribute. If an input does change, get the value from whoever produced the record, and send anything it can&amp;rsquo;t classify to review.&lt;/p&gt;&#10;&lt;p&gt;The agent&amp;rsquo;s recommendation was internally consistent and sounded responsible. It took someone who knew where the taxable event comes from to see that the attribute changed nothing. When an agent proposes reference data, run the flip test on every attribute before accepting it.&lt;/p&gt;&#10;</description></item><item><title>When the server must compute on confidential data, split the data by who may read it</title><link>https://eghuro.cz/posts/split-confidential-data-by-reader/</link><pubDate>Sun, 21 Jun 2026 20:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/split-confidential-data-by-reader/</guid><description>&lt;p&gt;I designed a web app that computes a person&amp;rsquo;s tax on their investment trades. Professional advisory firms run it for their own clients. Clients connect broker APIs or upload statements, and the server runs lot matching, currency conversion, and the holding-period tests. A client with a messy case can hand it to an advisor.&lt;/p&gt;&#10;&lt;p&gt;The confidentiality requirement may sound contradictory. The advisor owes the client professional secrecy from the first contact. The platform operator should not be able to read client data at rest, and the same goes for a stolen backup, a tenant boundary bug, or a subpoena served on the platform. The server still has to compute on all of it, including in background jobs while the user is offline.&lt;/p&gt;&#10;&lt;p&gt;Neither usual answer works:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Disk or database encryption&lt;/strong&gt; protects against a stolen disk. The operator, its DBAs, and a subpoena still get plaintext.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Client-side end-to-end encryption&lt;/strong&gt; fails because the server is the calculator. Background sync, recomputation after new data, and the advisor&amp;rsquo;s view all need plaintext on the server. In a web app it is weaker than it sounds anyway: the server ships the JavaScript on every load, so an operator that serves malicious code gets the password.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;What worked was a question I had skipped: which fields does the computation actually read?&lt;/p&gt;&#10;&#10;&lt;h2 id="list-what-the-engine-needs"&gt;List what the engine needs &lt;/h2&gt;&#10;&lt;p&gt;For every realized event, the tax engine needs a date, a quantity, amounts in the reporting currency, fees, an event type, and an asset category (security, crypto, derivative). To match lots, it has to know that two trades are the same instrument. It doesn&amp;rsquo;t need the instrument&amp;rsquo;s name, ticker, or ISIN, and it doesn&amp;rsquo;t need the broker, account number, or person.&lt;/p&gt;&#10;&lt;p&gt;One step needs real identity: classifying an instrument into a category. A broker may report a CFD under &amp;ldquo;stocks&amp;rdquo;, and a share can be fractional or whole. That happens once, at ingestion, inside a per-broker adapter. The adapter sees identity in memory, emits a de-identified, categorized record, and the identity goes elsewhere.&lt;/p&gt;&#10;&lt;p&gt;So the data splits into two zones, each with its own readers:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Zone 1: tax skeleton&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Zone 2: identity vault&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Contents&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;surrogate instrument ID, category, dates, quantity, amounts, fees, event type&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;surrogate → name/ticker/ISIN, broker, account, personal data&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Encrypted under&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;per-tenant key encryption key in a KMS (envelope encryption)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;key chain rooted in the user&amp;rsquo;s password&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Readers&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;engine (in memory, during compute), the firm after handoff&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the user in session, the firm after handoff&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Platform operator&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ciphertext at rest; if it decrypts, it gets anonymous arithmetic&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ciphertext only&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Background jobs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;read and write&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;write only, through a public-key layer&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Lost password&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;survives; the case can still be computed and filed&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;lost unless the user has the recovery code&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Zone 1 is intentionally not under the user&amp;rsquo;s key. Under the user&amp;rsquo;s key, the background sync, the in-year recompute, and the advisor&amp;rsquo;s computation would all stop working as soon as the user logged out. Background jobs write to Zone 2 by encrypting to the user&amp;rsquo;s public key. Only a logged-in user can decrypt.&lt;/p&gt;&#10;&#10;&lt;h2 id="surrogate-ids-a-keyed-hash-scoped-per-user"&gt;Surrogate IDs: a keyed hash, scoped per user &lt;/h2&gt;&#10;&lt;p&gt;Lot matching groups trades by instrument, so the skeleton needs a stable ID that is not the instrument. The design is an HMAC with a per-user key held in the KMS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Stable across one user&amp;#39;s accounts and years, unlinkable across users.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;deriveSurrogateInstrumentId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;instrument&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;broker&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;instrument&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;broker&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;surrogate requires instrument identity and broker&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;kmsHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`surrogate:user:&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;instrument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;broker&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each alternative breaks one requirement:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A plain hash of the ISIN falls to a dictionary attack. The set of listed instruments is small, so hashing them all takes seconds.&lt;/li&gt;&#10;&lt;li&gt;One global HMAC key makes the same instrument correlate across every user in a dump.&lt;/li&gt;&#10;&lt;li&gt;A random ID per trade cannot be recomputed. Next year&amp;rsquo;s sync, or a second account at another broker, has to land on the same ID without reading the vault, or carried-in lots stop matching.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The keyed hash protects a database dump. An operator who can call the KMS live can still test &amp;ldquo;does this user hold this instrument&amp;rdquo;. That is past the threat model&amp;rsquo;s at-rest boundary, so write it down instead of claiming more.&lt;/p&gt;&#10;&#10;&lt;h2 id="access-and-liability-begin-at-the-same-instant"&gt;Access and liability begin at the same instant &lt;/h2&gt;&#10;&lt;p&gt;Before handoff, the firm&amp;rsquo;s advisors have no access to a prospect&amp;rsquo;s case. For identity, the user&amp;rsquo;s key enforces that cryptographically. The anonymous skeleton sits under the firm&amp;rsquo;s own key, so for it, access control is the barrier. It matches the legal position: no engagement contract, no professional liability, only precontractual confidentiality.&lt;/p&gt;&#10;&lt;p&gt;Handoff is one atomic event. The engagement contract forms, the firm&amp;rsquo;s anti-money-laundering (AML) and know-your-customer (KYC) duties start, and the vault&amp;rsquo;s relevant portion is rewrapped to the firm&amp;rsquo;s key. Data access and liability start together, so neither exists without the other.&lt;/p&gt;&#10;&lt;p&gt;The coding agent&amp;rsquo;s first draft at this step was wrong. It proposed default-minimal disclosure: share only the vault fields the flagged items needed. I rejected it because an advisor who takes on liability and an AML duty cannot sign off on a case half-blind. The boundary that held was &lt;strong&gt;purpose, not field sensitivity&lt;/strong&gt;: everything the past outcome was built from transfers in full, and anything forward-looking (plans, rebalancing ideas, notes) never transfers. Data minimization still applies, but the line runs between past and future, not through the case.&lt;/p&gt;&#10;&#10;&lt;h2 id="one-password-auto-unlock-never-transmitted-pick-an-apake"&gt;One password, auto-unlock, never transmitted: pick an aPAKE &lt;/h2&gt;&#10;&lt;p&gt;The first plan was standard session login plus a client-side KDF for the vault key, with OPAQUE as a later upgrade. That leaves two bad choices:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Two passwords&lt;/strong&gt;: one for login, sent to the server, and one for the vault, used only in the browser. Users hate it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One password, still POSTed for login, also used to derive the vault key.&lt;/strong&gt; It gives auto-unlock, and it quietly hands the operator the vault password on every login. Zone 2 stops protecting anything.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;With a standard login, one password cannot also stay off the server. An asymmetric PAKE fixes the login side. &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9807"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OPAQUE (RFC 9807)&lt;/a&gt;&#10; authenticates without the server learning the password, and gives the client an &lt;code&gt;export_key&lt;/code&gt; that the server never sees. Root the vault in it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// K_vault = HKDF(export_key); unwraps user key → RSA private key → vault entries&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;deriveVaultKeyBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportKey&lt;/span&gt;: &lt;span class="kt"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Uint8Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;export_key is required&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hkdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Uint8Array&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="nx"&gt;K_VAULT_INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A password reset is a new OPAQUE registration with a new &lt;code&gt;export_key&lt;/code&gt;. The mandatory recovery code rewraps the vault chain to the new root, and Zone 1 is untouched. Use a vetted library: the registration and login flows and the &lt;code&gt;export_key&lt;/code&gt; handling are now part of the vault&amp;rsquo;s security.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-tenant-boundary-belongs-in-the-database"&gt;The tenant boundary belongs in the database &lt;/h2&gt;&#10;&lt;p&gt;Each firm is a tenant, and one firm&amp;rsquo;s prospects must never show up in another firm&amp;rsquo;s queries. A forgotten &lt;code&gt;WHERE firm_id = ?&lt;/code&gt; should return nothing, not another tenant&amp;rsquo;s rows. With Postgres row-level security, three details decide whether it fails closed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;alter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;app_migrator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- the app role is not the owner&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;alter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- the owner obeys policies too&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;firm_isolation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;using&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firm_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;app.firm_id&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;check&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firm_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;app.firm_id&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="sb"&gt;`select set_config(&amp;#39;app.firm_id&amp;#39;, &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;firmId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;, true)`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true = transaction-local&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;Table owners bypass RLS unless it is forced. Superusers and &lt;code&gt;BYPASSRLS&lt;/code&gt; roles bypass it always, so the app must connect as neither.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;current_setting&lt;/code&gt; without &lt;code&gt;missing_ok&lt;/code&gt;, plus the &lt;code&gt;::uuid&lt;/code&gt; cast, raises when the tenant was never set or is empty. A missing tenant is an error, not an empty match.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;set_config(..., true)&lt;/code&gt; resets at commit or rollback. With &lt;code&gt;false&lt;/code&gt;, a pooled connection carries one firm&amp;rsquo;s ID into the next request, and a transaction-mode pooler can hand the setting to a different client.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="write-down-the-floors"&gt;Write down the floors &lt;/h2&gt;&#10;&lt;p&gt;The design makes specific claims and lists what it does not cover. That list is part of the design:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Plaintext exists in memory during ingestion and compute. The claim is &amp;ldquo;zero to the operator at rest&amp;rdquo;, never &amp;ldquo;the platform never sees it&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;amount ÷ quantity&lt;/code&gt; approximates a unit price that can be matched against market data. It is exploitable only by someone who already decrypted Zone 1.&lt;/li&gt;&#10;&lt;li&gt;The web JavaScript trust gap stays open. OPAQUE stops offline cracking of a stolen verifier table, not a malicious bundle. A native client is the eventual fix.&lt;/li&gt;&#10;&lt;li&gt;An API-connected account reveals which broker it is, because the token names it. A manual upload keeps the broker in Zone 2. The user gets that choice in the UI.&lt;/li&gt;&#10;&lt;li&gt;A forgotten password without the recovery code loses the labels. The skeleton survives, and the user is told to keep their own copies of the source statements.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;When the server has to be the calculator, don&amp;rsquo;t choose between &amp;ldquo;encrypt the disk&amp;rdquo; and &amp;ldquo;encrypt everything client-side&amp;rdquo;. List the readers (user, professional, engine, operator) and what each one needs. Then check whether the computation can run without identity. When it can, identity moves into a zone the computation never opens, and the remaining decisions follow from that: keyed surrogates, a handoff that rewraps keys, an aPAKE for the single password.&lt;/p&gt;&#10;&lt;p&gt;In practice, I ran the design as an interview with a coding agent. It asked one question at a time, proposed options with a recommendation, and recorded each decision as an ADR with a &amp;ldquo;Why this is non-obvious&amp;rdquo; section: the design a reader would expect, and why it was rejected here. Several recommendations were reasonable but wrong. Two of my corrections removed whole planned pieces: the default-minimal handoff, and an &lt;a&#10; href="https://eghuro.cz/posts/check-the-attribute-reaches-the-computation"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;instrument catalog&lt;/a&gt;&#10; that event-driven classification made unnecessary.&lt;/p&gt;&#10;</description></item><item><title>An MCP server has more outputs than tool results</title><link>https://eghuro.cz/posts/mcp-server-more-outputs-than-tool-results/</link><pubDate>Tue, 19 May 2026 20:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/mcp-server-more-outputs-than-tool-results/</guid><description>&lt;p&gt;I designed a local stdio MCP server in Go that gives an LLM scoped access to a shared password vault. Two facts set the threat model. Any member of a shared vault can write an entry comment that another member&amp;rsquo;s agent later reads, so stored text is attacker-controlled. And the model can call every tool in the manifest, so whoever steers the model gets that authority too.&lt;/p&gt;&#10;&lt;p&gt;The plan started with a prompt-injection fence around user strings. Most of the work ended up elsewhere.&lt;/p&gt;&#10;&#10;&lt;h2 id="keep-the-secret-off-the-transcript"&gt;Keep the secret off the transcript &lt;/h2&gt;&#10;&lt;p&gt;Once &lt;code&gt;get_password&lt;/code&gt; returns plaintext, the value sits in the model&amp;rsquo;s context and the host&amp;rsquo;s chat history, and no scope rule can take it back. So the everyday path never crosses that boundary:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;copy_password_to_clipboard&lt;/code&gt; takes an ID, writes the value to the OS clipboard from inside the server, and returns &lt;code&gt;Password (id=42) copied to clipboard.&lt;/code&gt; Auto-clear compares a SHA-256 of the current clipboard first, so it can&amp;rsquo;t wipe something the user copied later.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;create_password_generated&lt;/code&gt; generates the value server-side and returns only the new row&amp;rsquo;s ID.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;get_password&lt;/code&gt; returns the literal &lt;code&gt;&amp;lt;redacted; revealPlaintext=never&amp;gt;&lt;/code&gt; by default, and its description points the model at the clipboard tool.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The confirmation first included the fenced entry title. That was removed: the model already has the title, and &amp;ldquo;action just completed&amp;rdquo; is a bad moment to hand it attacker-written text. A tool result should echo as little as possible.&lt;/p&gt;&#10;&#10;&lt;h2 id="annotations-are-hints-so-consent-lives-in-the-server"&gt;Annotations are hints, so consent lives in the server &lt;/h2&gt;&#10;&lt;p&gt;Plaintext in chat remains for cases where the model has to reason about the value, such as a reuse audit. It sits behind a setting: &lt;code&gt;never&lt;/code&gt;, &lt;code&gt;per_call&lt;/code&gt; or &lt;code&gt;always&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;That setting began as a boolean, and the trap was in the annotations. A read is correctly &lt;code&gt;readOnlyHint=true&lt;/code&gt;, so the host&amp;rsquo;s confirmation never fires, and one injected instruction could walk &lt;code&gt;list_passwords&lt;/code&gt; → &lt;code&gt;get_password&lt;/code&gt; across every entry in scope. Marking the read as destructive doesn&amp;rsquo;t help: annotations are static at registration, and hosts may ignore the cue on a read-only tool.&lt;/p&gt;&#10;&lt;p&gt;So &lt;code&gt;per_call&lt;/code&gt; is an OS-native modal raised inside the server before the value is serialized, with a 30-second timeout that fails closed. If no GUI is available, the server refuses to start in that mode.&lt;/p&gt;&#10;&lt;p&gt;Two more manifest details matter. &lt;code&gt;destructiveHint&lt;/code&gt; and &lt;code&gt;openWorldHint&lt;/code&gt; default to true in the spec, which is why the Go SDK types them as &lt;code&gt;*bool&lt;/code&gt;, and every tool here sets them explicitly. And the manifest itself is a control: if the clipboard probe fails, the tool is never registered, so the model can&amp;rsquo;t try it.&lt;/p&gt;&#10;&#10;&lt;h2 id="authority-lives-in-code"&gt;Authority lives in code &lt;/h2&gt;&#10;&lt;p&gt;Tool descriptions are instructions to something an attacker may be steering, so they count as guidance, not control. Scope is a deterministic filter on &lt;code&gt;(vault, [tags], mode)&lt;/code&gt;, and the type system gates writes:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;scopedOps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;DeletePassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ResolvedRule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A rule only comes from the scope resolver, so a handler that skips the check has nothing to pass and doesn&amp;rsquo;t compile. A runtime check catches the wrong rule: another vault, or a read-only mode. The scope itself lives in the OS keychain, and the server only reads it so that no tool call can widen it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-input-schema-enforces-less-than-it-looks-like"&gt;The input schema enforces less than it looks like &lt;/h2&gt;&#10;&lt;p&gt;With the Go SDK, &lt;code&gt;mcp.AddTool&lt;/code&gt; derives the input schema from struct tags. An earlier tag format, &lt;code&gt;jsonschema:&amp;quot;required,description=...&amp;quot;&lt;/code&gt;, failed at registration because the &lt;code&gt;jsonschema&lt;/code&gt; tag is now the property description and nothing else. The tags were rewritten as prose:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;Mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;mode,omitempty&amp;#34; jsonschema:&amp;#34;Strategy. (Schema hints: enum=memorable; enum=random.)&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;Length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;length,omitempty&amp;#34; jsonschema:&amp;#34;Length. (Schema hints: minimum=6; maximum=32.)&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads like a constraint and enforces nothing. The generated schema has only &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;, and a call with &lt;code&gt;mode=evil, length=999&lt;/code&gt; reaches the handler. The handler&amp;rsquo;s own validation is the only check, and it has to reject: the generator below it silently clamps, so the model would ask for one thing and get another.&lt;/p&gt;&#10;&lt;p&gt;The schema does give you types, required fields, and &lt;code&gt;additionalProperties: false&lt;/code&gt;. When it rejects a call, the SDK answers before your handler runs, so your audit middleware never sees it, and the message echoes the offending value.&lt;/p&gt;&#10;&lt;p&gt;Beyond that, arguments are IDs, enums, and bounded integers. Omitted and &lt;code&gt;[]&lt;/code&gt; mean different things on an update, so that field is a &lt;code&gt;*[]int64&lt;/code&gt;. The model can&amp;rsquo;t push a row out of its own visibility, because one allowed tag must remain.&lt;/p&gt;&#10;&lt;p&gt;Stored content is deliberately not filtered. A comment reading &amp;ldquo;ignore previous instructions&amp;rdquo; is legal data, and the regular clients show it verbatim. Escaping happens when text leaves for the model.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fence-and-the-encoder-after-it"&gt;The fence, and the encoder after it &lt;/h2&gt;&#10;&lt;p&gt;Read tools wrap each user string, and their descriptions tell the model that fenced text is data:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;lt;user_content&amp;gt;{escaped}&amp;lt;/user_content&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; are HTML-escaped so a stored payload can&amp;rsquo;t close the tag. C0 controls are escaped too, because a stored ESC paints ANSI sequences on the terminal of anyone inspecting raw traffic, and the nine bidi overrides are replaced so stored text can&amp;rsquo;t reorder what a human approves.&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;Ignore previous instructions&amp;rdquo; passes through unchanged. Against natural-language injection, the fence makes the boundary explicit. The real limit is what a steered model can reach.&lt;/p&gt;&#10;&lt;p&gt;Two gaps remain, both visible only by running it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;The tags don&amp;rsquo;t arrive as written.&lt;/strong&gt; Returning a typed &lt;code&gt;Out&lt;/code&gt; lets the SDK serialize it with &lt;code&gt;encoding/json&lt;/code&gt;, which HTML-escapes by default. A title reaches the model&amp;rsquo;s text block as &lt;code&gt;&amp;lt;user_content&amp;gt;...&lt;/code&gt;, and &lt;code&gt;&amp;amp;amp;&lt;/code&gt; becomes &lt;code&gt;&amp;amp;amp;&lt;/code&gt;. The payload still can&amp;rsquo;t close the fence, but the literal the tool description promises isn&amp;rsquo;t the literal on the wire. To control it, set &lt;code&gt;result.Content&lt;/code&gt; yourself using an encoder with &lt;code&gt;SetEscapeHTML(false)&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The escape table works on bytes.&lt;/strong&gt; It covers bytes below &lt;code&gt;0x20&lt;/code&gt;, so DEL and U+009B, the 8-bit CSI encoded as &lt;code&gt;C2 9B&lt;/code&gt;, pass through. A rune-based check with &lt;code&gt;unicode.IsControl&lt;/code&gt; catches both.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="each-sink-needs-its-own-treatment"&gt;Each sink needs its own treatment &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Sink&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Data&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Treatment&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tool result&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Free text&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Escape, replace bidi overrides, fence. Keep ZWJ and RLM, which Arabic, Indic scripts, and emoji need&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tool result&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Vault and tag names&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Strip control and format runes, cap at 256, then fence, which still protects a handler that forgets the fence&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Startup error&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Backend-supplied version&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Allowlist regex, otherwise &lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Consent dialog&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Entry title&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Reject controls, line separators and bidi; truncate to 80 runes&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Subprocess&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Entry title&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Environment variable, never script source&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Audit log&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Content fields&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Dropped; a search query is logged as its length&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tool error&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Anything&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Fixed text&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The consent dialog shows why one sanitizer isn&amp;rsquo;t enough. The AppleScript is a constant, and the title arrives as data:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-applescript" data-lang="applescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="nb"&gt;system attribute&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ENTRY_TITLE&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;display dialog&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Reveal password \&amp;#34;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;\&amp;#34; to your AI assistant?&amp;#34;&lt;/span&gt; &lt;span class="c"&gt;-- ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That stops code injection and leaves another problem. The dialog still renders the title, so newlines, bidi overrides, or a long payload could bury the warning and harvest a click on Allow. Data-not-code and safe-to-display are separate properties. The interpreter is called by absolute path as well, because a &lt;code&gt;PATH&lt;/code&gt;-shadowed binary would receive the title in its environment.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-error-path-talks-to-the-model"&gt;The error path talks to the model &lt;/h2&gt;&#10;&lt;p&gt;In the Go SDK, a non-nil error from a tool handler becomes &lt;code&gt;isError&lt;/code&gt; content, so &lt;code&gt;err.Error()&lt;/code&gt; goes to the model. Every handler here runs through a middleware that classifies the error, returns fixed text, and sends the reason to the audit log:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;wrapHandler&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;In&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ToolHandlerFor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CallToolRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;In&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// named: recover writes these&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;recover&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;errGeneric&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nb"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="c1"&gt;// deadline, session check, inner(), classify, audit&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The named returns matter. Without them, a recovered panic returns &lt;code&gt;(nil, zero, nil)&lt;/code&gt;, which the host reads as an empty success. The panic value is logged by type, and by content only if it&amp;rsquo;s a string or error, because &lt;code&gt;%v&lt;/code&gt; on a struct could print a password.&lt;/p&gt;&#10;&lt;p&gt;What still had to leave the error text:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Scope details.&lt;/strong&gt; A bypass error included the rule&amp;rsquo;s vault ID and mode, so a steered model could map the scope one call at a time. Those fields moved to a typed error for the audit log.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Subprocess output.&lt;/strong&gt; &lt;code&gt;fmt.Errorf(&amp;quot;pbcopy: %w: %s&amp;quot;, err, out)&lt;/code&gt; could carry clipboard content. Stdout now goes to &lt;code&gt;/dev/null&lt;/code&gt;, and stderr never joins the error.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Identity.&lt;/strong&gt; An &amp;ldquo;account changed&amp;rdquo; error named the username and backend. It can fire on every tool call, putting N copies of an email address into a transcript that may get exported or summarized. The final text names nobody.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Tamperable integers.&lt;/strong&gt; A version-mismatch error quoted a schema number from a local file, whose author then controlled a value the model reads as authoritative.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The audit log is an output too. Its redactor is a key-name denylist over &lt;code&gt;any&lt;/code&gt;, and it needed six fixes: nested maps, &lt;code&gt;[]map[string]any&lt;/code&gt;, pointers and typed maps, nested structs, &lt;code&gt;Password&lt;/code&gt;/&lt;code&gt;URL&lt;/code&gt; casing, and map keys in result metadata. A new field called &lt;code&gt;token&lt;/code&gt; would still be logged. An allowlist of loggable fields changes that failure from a leaked secret to a missing log value.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-stdio-child-outlives-the-login"&gt;A stdio child outlives the login &lt;/h2&gt;&#10;&lt;p&gt;The host spawns the server once and keeps it alive for the chat. An ADR said the session is read on every call, so a login as a different account gets refused. The implementation read it once at startup, and the error sentinels existed with no code path producing them. A second login with a chat open left the server acting as the old account, with no warning.&lt;/p&gt;&#10;&lt;p&gt;The check now runs in the middleware, before each handler. It came with a race: a check could pass, then a sibling refresh could swap the credentials before the handler used them. The check and the credential snapshot now happen in one critical section, and the handler receives that snapshot through its context.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-stays-open"&gt;What stays open &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Natural-language injection is contained, not solved. The scope rules limit the blast radius.&lt;/li&gt;&#10;&lt;li&gt;The tag filter runs on the server after the backend returns the whole vault, so the binary is trusted. A server-issued scoped token is the real fix.&lt;/li&gt;&#10;&lt;li&gt;Auto-clear dies with the process, and clipboard managers keep their own history.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Treat the SDK as part of your attack surface: read what your schema really contains, and look at the bytes a tool result becomes. Then list every place a value can go, including errors, dialogs, and logs. Remove the sinks you can, as the clipboard tool did for secrets, and give each remaining sink the treatment its grammar calls for.&lt;/p&gt;&#10;</description></item><item><title>Codex 401 in an agent shell: check credentials before blaming API key precedence</title><link>https://eghuro.cz/posts/codex-inherited-api-key/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/codex-inherited-api-key/</guid><description>&lt;p&gt;A regular terminal reported a ChatGPT login, but Codex requests inside an agent&amp;rsquo;s shell failed with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;HTTP error: 401 Unauthorized&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An inherited &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; looked suspicious. The useful next step was a controlled credential-context comparison. The recorded investigation never proved that the key overrode the ChatGPT login, and it never showed a successful retry after removing it.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-the-may-5-investigation-established"&gt;What the May 5 investigation established &lt;/h2&gt;&#10;&lt;p&gt;The installed CLI was &lt;code&gt;codex-cli 0.128.0&lt;/code&gt;. The user reported that &lt;code&gt;codex login status&lt;/code&gt; returned &lt;code&gt;Logged in using ChatGPT&lt;/code&gt; before launching the agent. Inside the agent, the same status command returned &lt;code&gt;Not logged in&lt;/code&gt;, and this request repeatedly failed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;codex &lt;span class="nb"&gt;exec&lt;/span&gt; --skip-git-repo-check &lt;span class="s2"&gt;&amp;#34;echo hello&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An early environment check found &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; present. A separate request to the OpenAI models endpoint, explicitly using that variable, returned HTTP 401. That established that the credential failed that direct probe. It did not establish which credential Codex selected.&lt;/p&gt;&#10;&lt;p&gt;Later checks found the variable absent, yet Codex still failed. One retry explicitly supplied an empty &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; and also received 401 responses. The chain contains no controlled &lt;code&gt;env -u&lt;/code&gt; comparison that restored subscription access. The document-cleanup task continued with Python; Codex authentication was not shown to recover.&lt;/p&gt;&#10;&lt;p&gt;Calling this a confirmed case of an environment key shadowing OAuth would overstate the evidence. The login-status mismatch deserved investigation before declaring the subscription token expired or replacing it with an API key.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-the-launch-contexts-without-exposing-secrets"&gt;Compare the launch contexts without exposing secrets &lt;/h2&gt;&#10;&lt;p&gt;Run these checks locally in both the working terminal and the failing agent shell:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;command&lt;/span&gt; -v codex&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;codex --version&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;codex login status&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 - &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;PY&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;import os&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;for name in (&amp;#34;HOME&amp;#34;, &amp;#34;CODEX_HOME&amp;#34;, &amp;#34;OPENAI_API_KEY&amp;#34;, &amp;#34;CODEX_API_KEY&amp;#34;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; print(f&amp;#34;{name}: {&amp;#39;set&amp;#39; if name in os.environ else &amp;#39;unset&amp;#39;}&amp;#34;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PY&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Keep login-status output private if it includes credential details. The Python check prints names and presence only. Do not dump the environment, search credential files, or paste tokens into a troubleshooting conversation.&lt;/p&gt;&#10;&lt;p&gt;Compare the actual &lt;code&gt;HOME&lt;/code&gt; and &lt;code&gt;CODEX_HOME&lt;/code&gt; paths privately, too. Presence alone cannot tell you whether two processes use the same directory. Check the executable and launch configuration for differences before changing authentication.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://developers.openai.com/codex/auth"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OpenAI&amp;rsquo;s authentication documentation&lt;/a&gt;&#10; distinguishes ChatGPT subscription sign-in from API-key access. It also describes credential storage in a file under &lt;code&gt;CODEX_HOME&lt;/code&gt; or an OS credential store. A &lt;a&#10; href="https://eghuro.cz/posts/profile-home-tilde-paths"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;profile HOME mismatch&lt;/a&gt;&#10; or unavailable credential store is a separate explanation for a missing login.&lt;/p&gt;&#10;&lt;p&gt;To isolate the suspected variable on a Unix-like system, keep the same executable, working directory, &lt;code&gt;HOME&lt;/code&gt;, and &lt;code&gt;CODEX_HOME&lt;/code&gt;, then compare:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;codex login status&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;env -u OPENAI_API_KEY codex login status&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Removing one variable affects only the second process. It does not delete the saved login or change the parent shell. If you intend to make a model request, repeat the same minimal prompt with and without that variable and compare completion and exit status. A status check alone does not prove a request will succeed. This paired request test was not completed in the May investigation.&lt;/p&gt;&#10;&#10;&lt;h2 id="verification-on-october-31-avoid-a-universal-precedence-rule"&gt;Verification on October 31: avoid a universal precedence rule &lt;/h2&gt;&#10;&lt;p&gt;A separate check on 2026-10-31 used installed Codex &lt;code&gt;0.166.0&lt;/code&gt; with isolated, empty &lt;code&gt;HOME&lt;/code&gt; and &lt;code&gt;CODEX_HOME&lt;/code&gt; directories. &lt;code&gt;codex login status&lt;/code&gt; returned &lt;code&gt;Not logged in&lt;/code&gt; both without a key and with a dummy &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;. No model request or real credential was used. This checks status behavior without a saved login; it does not test precedence over OAuth.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/openai/codex/blob/67f0994b532b8740f14b5b738af7b7b2a915b1d6/codex-rs/login/src/auth/manager.rs#L1571-L1593"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;upstream authentication loader&lt;/a&gt;&#10;, inspected that day, has an environment-key precedence branch for &lt;code&gt;CODEX_API_KEY&lt;/code&gt;, conditional on that mechanism being enabled and API-key authentication being allowed. That is not a universal rule about &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, nor proof of what version &lt;code&gt;0.128.0&lt;/code&gt; selected in May.&lt;/p&gt;&#10;&lt;p&gt;The earlier trusted-directory error was separate: &lt;code&gt;--skip-git-repo-check&lt;/code&gt; bypassed that check and exposed the authentication failure. Initializing unrelated directories as Git repositories would not repair credentials.&lt;/p&gt;&#10;&lt;p&gt;Before logging out or deleting a working login, compare the credential context of your two launch paths and change one variable at a time.&lt;/p&gt;&#10;</description></item><item><title>Tilde pointed at a profile home, so the export landed elsewhere</title><link>https://eghuro.cz/posts/profile-home-tilde-paths/</link><pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/profile-home-tilde-paths/</guid><description>&lt;p&gt;The export command finished, but the expected directory was empty. Then Python reported &lt;code&gt;[Errno 2] No such file or directory&lt;/code&gt; for a script that existed in the user&amp;rsquo;s home. The runner&amp;rsquo;s &lt;code&gt;HOME&lt;/code&gt; pointed at a profile-specific directory. Fixing this required explicit paths for both the export destination and the Python environment.&lt;/p&gt;&#10;&#10;&lt;h2 id="two-homes-two-plausible-directory-trees"&gt;Two homes, two plausible directory trees &lt;/h2&gt;&#10;&lt;p&gt;An initial attempt to read a helper resolved to a profile-home path where the file did not exist. During the later export, &lt;code&gt;cd ~/transfer&lt;/code&gt; succeeded because that directory existed too. A virtual environment had been created there, but the actual transfer script lived under the user&amp;rsquo;s expected home.&lt;/p&gt;&#10;&lt;p&gt;The terminal output settled the discrepancy: printing &lt;code&gt;HOME&lt;/code&gt; returned the profile-specific home. Comparing the two transfer directories found the script only under the expected user-home root. Comparing output directories found exported data under that root and an empty directory under the profile home. The report also existed only under the expected root.&lt;/p&gt;&#10;&lt;p&gt;Changing directories alone would still leave Python&amp;rsquo;s &lt;code&gt;Path(&amp;quot;~/data&amp;quot;).expanduser()&lt;/code&gt; following the process&amp;rsquo;s &lt;code&gt;HOME&lt;/code&gt;. The repair replaced tilde-based output defaults with explicit absolute destinations, including separate report and log paths. The script supported overrides such as &lt;code&gt;KB_OUT_ROOT&lt;/code&gt;. Running it from the intended transfer directory also selected that directory&amp;rsquo;s &lt;code&gt;.venv/bin/python&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;This was observed in a historical Hermes runner environment. It does not establish that every Hermes profile changes &lt;code&gt;HOME&lt;/code&gt;; consult the &lt;a&#10; href="https://hermes-agent.nousresearch.com/docs"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;current Hermes documentation&lt;/a&gt;&#10; for your setup. A configured profile directory and the home directory you expect are separate facts to check.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-the-process-before-searching-for-files"&gt;Check the process before searching for files &lt;/h2&gt;&#10;&lt;p&gt;Run these inside the same terminal tool or wrapper that launches the job:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;HOME=%s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;id&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -c &lt;span class="s1"&gt;&amp;#39;import sys; from pathlib import Path; print(sys.executable); print(Path.home())&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Compare those paths with the expected project root. Limit directory checks to those known roots; a filesystem-wide search adds noise and can expose unrelated private files. A successful &lt;code&gt;cd&lt;/code&gt; proves only that a directory exists, not that it contains the intended script or environment.&lt;/p&gt;&#10;&#10;&lt;h2 id="shell-expansion-happens-before-the-child-starts"&gt;Shell expansion happens before the child starts &lt;/h2&gt;&#10;&lt;p&gt;In Bash, unquoted &lt;code&gt;~/data&lt;/code&gt; expands in the calling shell. In &lt;code&gt;env HOME=/somewhere python3 ... ~/data&lt;/code&gt;, the argument has already expanded before &lt;code&gt;env&lt;/code&gt; changes the child&amp;rsquo;s environment. Single-quoted &lt;code&gt;'~/data'&lt;/code&gt; stays literal; Python must expand it explicitly. On Unix, &lt;code&gt;Path.expanduser()&lt;/code&gt; uses &lt;code&gt;HOME&lt;/code&gt; when it is set.&lt;/p&gt;&#10;&lt;p&gt;This isolated example demonstrates both cases without touching real home directories:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;SCRATCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="p"&gt;:?Set TMPDIR to your scratch directory&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/home-path-demo&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir -p &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/caller-home&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/profile-home&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;PYTHON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; -v python3&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; SCRATCH PYTHON&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/caller-home&amp;#34;&lt;/span&gt; bash --noprofile --norc &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;SH&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;env HOME=&amp;#34;$SCRATCH/profile-home&amp;#34; &amp;#34;$PYTHON&amp;#34; -c \&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; &amp;#39;import sys; print(sys.argv[1])&amp;#39; ~/data&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;env HOME=&amp;#34;$SCRATCH/profile-home&amp;#34; &amp;#34;$PYTHON&amp;#34; -c \&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; &amp;#39;import sys; from pathlib import Path; print(sys.argv[1]); print(Path(sys.argv[1]).expanduser())&amp;#39; &amp;#39;~/data&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first command prints the caller&amp;rsquo;s home path. The second prints the literal &lt;code&gt;~/data&lt;/code&gt;, then the profile-home path.&lt;/p&gt;&#10;&#10;&lt;h2 id="pin-the-interpreter-and-verify-the-artifact"&gt;Pin the interpreter and verify the artifact &lt;/h2&gt;&#10;&lt;p&gt;Here is a standalone local inventory example. The program reads its input directory as read-only; the output root is explicit. It requires Python&amp;rsquo;s &lt;code&gt;venv&lt;/code&gt; module and uses only scratch fixtures.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;SCRATCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="p"&gt;:?Set TMPDIR to your scratch directory&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/home-path-demo&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir -p &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/source&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/exports&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;fixture\n&amp;#39;&lt;/span&gt; &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/source/sample.txt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -m venv --without-pip &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/venv&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;DATA_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/source&amp;#34;&lt;/span&gt; &lt;span class="nv"&gt;KB_OUT_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/exports&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRATCH&lt;/span&gt;&lt;span class="s2"&gt;/venv/bin/python&amp;#34;&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;PY&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;import os&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;import sys&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;from pathlib import Path&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;source = Path(os.environ[&amp;#34;DATA_ROOT&amp;#34;])&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;out = Path(os.environ[&amp;#34;KB_OUT_ROOT&amp;#34;])&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;if not source.is_absolute() or not out.is_absolute():&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; raise ValueError(&amp;#34;Use absolute input and output roots&amp;#34;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;artifact = out / &amp;#34;inventory.txt&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;artifact.write_text(&amp;#34;\n&amp;#34;.join(sorted(p.name for p in source.glob(&amp;#34;*.txt&amp;#34;))) + &amp;#34;\n&amp;#34;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;assert artifact.read_text() == &amp;#34;sample.txt\n&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;print(&amp;#34;interpreter:&amp;#34;, sys.executable)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;print(&amp;#34;verified artifact:&amp;#34;, artifact.resolve())&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PY&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For a real export, validate expected contents at the selected destination and check the report there. In the failed run, a pipeline ending in &lt;code&gt;tail&lt;/code&gt; returned zero even though Python could not open the script. Command status alone missed both the launch failure and the path mismatch. Check your job&amp;rsquo;s resolved interpreter and output root before its next run.&lt;/p&gt;&#10;</description></item><item><title>Filename HTML injection: entities bypass a raw URL blocklist</title><link>https://eghuro.cz/posts/filename-html-injection-entity-encoding-beats-the-server-filter/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/filename-html-injection-entity-encoding-beats-the-server-filter/</guid><description>&lt;p&gt;A filename filter that rejects &lt;code&gt;https://&lt;/code&gt; can still admit an HTML attribute containing &lt;code&gt;https&amp;amp;#58;&amp;amp;#47;&amp;amp;#47;&lt;/code&gt;. If an application inserts that filename into HTML without escaping it, the parser decodes the character references and produces a usable URL. Put the repair at the rendering boundary: render filenames as text, even when another client already validates them.&lt;/p&gt;&#10;&lt;p&gt;This example synthetically demonstrates the encoding mismatch. It makes no claim about a particular product, deployment, or mail-client version.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-filter-and-the-renderer-see-different-strings"&gt;The filter and the renderer see different strings &lt;/h2&gt;&#10;&lt;p&gt;The vulnerable flow has two independent mistakes:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;A producer stores attacker-controlled text that another component assumes is safe because a UI restricts filename characters.&lt;/li&gt;&#10;&lt;li&gt;An HTML generator interpolates that text as markup. A server-side blocklist searches its stored spelling for literal URL schemes.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;An API, import job, or older client can bypass the first UI&amp;rsquo;s restrictions. The value can remain harmless in a web page that escapes it, then become markup in a generated email, export, or share preview that does not. In the API flow, validate the stored value only as data; do not treat it as safe HTML.&lt;/p&gt;&#10;&lt;p&gt;In an authorized test, a probe filename could contain:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;https&amp;amp;#58;&amp;amp;#47;&amp;amp;#47;example.invalid&amp;amp;#47;pixel&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here &lt;code&gt;&amp;amp;#58;&lt;/code&gt; represents a colon and &lt;code&gt;&amp;amp;#47;&lt;/code&gt; a slash. Attribute parsing resolves those references before the image loader consumes the URL. The reserved &lt;code&gt;.invalid&lt;/code&gt; domain keeps this example from contacting a real collection endpoint.&lt;/p&gt;&#10;&lt;p&gt;An image request can demonstrate remote-content loading under the tested settings. It does not establish JavaScript execution, arbitrary file access, or credential theft. A mail client may strip event handlers while retaining an image element, and it may block external images entirely.&lt;/p&gt;&#10;&#10;&lt;h2 id="reproduce-the-mismatch-without-sending-mail"&gt;Reproduce the mismatch without sending mail &lt;/h2&gt;&#10;&lt;p&gt;Save this as &lt;code&gt;check_filename.py&lt;/code&gt; and run &lt;code&gt;python3 check_filename.py&lt;/code&gt;. It uses Python&amp;rsquo;s HTML parser to inspect attribute values; it does not open a browser or make network requests.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;html&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;escape&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;html.parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTMLParser&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;lt;img src=&amp;#34;https&amp;amp;#58;&amp;amp;#47;&amp;amp;#47;example.invalid&amp;amp;#47;pixel&amp;#34;&amp;gt;.txt&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;http://&amp;#34;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://&amp;#34;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HTMLParser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_starttag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;img&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;unsafe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Inspect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;unsafe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;lt;p&amp;gt;Shared file: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;unsafe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;unsafe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;https://example.invalid/pixel&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Inspect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;lt;p&amp;gt;Shared file: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Shared file: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;unsafe: URL decoded; safe: filename preserved as text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The safe version escapes the ampersands as well as the angle brackets. The parser displays the original entity spelling as filename text; it does not reparse that displayed text into another HTML element.&lt;/p&gt;&#10;&#10;&lt;h2 id="prevent-markup-at-every-filename-sink"&gt;Prevent markup at every filename sink &lt;/h2&gt;&#10;&lt;p&gt;Use your template engine&amp;rsquo;s normal autoescaping for text content. In a DOM API, assign &lt;code&gt;textContent&lt;/code&gt;; avoid passing the filename to &lt;code&gt;innerHTML&lt;/code&gt;. Apply context-appropriate encoding if a value belongs in an attribute. A filename placed inside a URL requires URL construction and validation as well as HTML attribute escaping.&lt;/p&gt;&#10;&lt;p&gt;If a feature intentionally accepts rich HTML, use a maintained allowlist sanitizer with an explicit policy for external resources and URL schemes. Decoding and then extending the blocklist still leaves the application maintaining a second, incomplete interpretation of HTML. OWASP&amp;rsquo;s &lt;a&#10; href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;XSS prevention guidance&lt;/a&gt;&#10; covers the context-specific encoding rules.&lt;/p&gt;&#10;&lt;p&gt;Keep server-side filename rules for filesystem and application constraints. Those rules serve a different purpose from making text safe for an HTML sink.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-the-actual-consumer"&gt;Verify the actual consumer &lt;/h2&gt;&#10;&lt;p&gt;Test a filename created outside the restrictive UI, then follow it into each renderer. Inspect the generated HTML or MIME source as well as the visible result. For generated mail, test composition separately from recipient viewing.&lt;/p&gt;&#10;&lt;p&gt;On an authorized receiving client, use a controlled endpoint and record whether external content was enabled. Distinguish an outbound request from visible rendering and from script execution; one result does not prove the others. After the fix, repeat the probe and confirm that the filename appears literally, no injected element exists, and no request attributable to it occurs.&lt;/p&gt;&#10;&lt;p&gt;Add this filename to the HTML generator regression tests. Testing only the upload validator would miss the boundary where the text became active HTML content.&lt;/p&gt;&#10;</description></item><item><title>If GPG is missing, signature verification must fail closed</title><link>https://eghuro.cz/posts/fail-closed-signature-verification-before-extraction/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/fail-closed-signature-verification-before-extraction/</guid><description>&lt;p&gt;An updater effectively contained the equivalent of &lt;code&gt;if gpg: verify_signature()&lt;/code&gt;, then extracted the downloaded archive unconditionally. On a minimal installation without the optional binding, unsigned content took the trusted path. If authenticity is required, treat an unavailable verifier as a hard failure, not permission to continue.&lt;/p&gt;&#10;&lt;p&gt;This pattern is &lt;a&#10; href="https://cwe.mitre.org/data/definitions/347.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;CWE-347, improper verification of a cryptographic signature&lt;/a&gt;&#10;. It becomes more dangerous when the next stage expands an archive, loads a plugin, flashes firmware, or executes an installer, because the bypass then feeds directly into execution.&lt;/p&gt;&#10;&#10;&lt;h2 id="optional-crypto-turns-verification-into-best-effort"&gt;Optional crypto turns verification into best effort &lt;/h2&gt;&#10;&lt;p&gt;This shape is unsafe:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;SignatureError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bad signature&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Runs when no verifier exists.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The absence branch is the bypass. During review, search for trust decisions guarded by import success, feature flags, platform checks, or broad exception handlers, then follow each branch to the first sensitive consumer. A green path should require a positive verification result.&lt;/p&gt;&#10;&lt;p&gt;An attacker still needs influence over the downloaded payload or its source. That may come from a compromised update endpoint, repository, mirror, DNS or TLS failure, or another write primitive. Missing verification does not by itself prove remote code execution; it removes the authenticity barrier that should contain those failures.&lt;/p&gt;&#10;&#10;&lt;h2 id="archive-extraction-can-turn-bypass-into-a-file-write"&gt;Archive extraction can turn bypass into a file write &lt;/h2&gt;&#10;&lt;p&gt;A second bug often makes the impact concrete: archive members such as &lt;code&gt;../../outside&lt;/code&gt; or symlinks that escape the destination. Joining a destination with an attacker-controlled member name does not remove traversal so the archive bug can turn the bypass into a file write.&lt;/p&gt;&#10;&lt;p&gt;Python added extraction filters in 3.12, and Python 3.14 changed the default to the &lt;code&gt;data&lt;/code&gt; filter. The &lt;a&#10; href="https://docs.python.org/3/library/tarfile.html#extraction-filters"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;current &lt;code&gt;tarfile&lt;/code&gt; documentation&lt;/a&gt;&#10; still recommends explicit filtering and additional limits because no built-in filter stops every denial-of-service or filesystem hazard.&lt;/p&gt;&#10;&lt;p&gt;This local fixture asserts both failure paths. Save it as &lt;code&gt;verify_extract.py&lt;/code&gt; and run it only in a disposable directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;tarfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;tempfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VerificationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_and_extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;VerificationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;signature verifier unavailable&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;VerificationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;signature rejected&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tarfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;data_filter&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;safe tar filter unavailable&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tarfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;r:*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extractall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;data&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;traversal_archive&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tarfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;test&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tarfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TarInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;../outside&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;traversal_archive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;verify_and_extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;sig&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;missing verifier was accepted&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;VerificationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;verify_and_extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;sig&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;traversal member was accepted&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;tarfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FilterError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;missing verifier and traversal archive rejected&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The fake verifier isolates extraction behavior; it is not production signature logic. The fixture passes on Python 3.12 or newer when the filter is available.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-verification-a-prerequisite-state"&gt;Make verification a prerequisite state &lt;/h2&gt;&#10;&lt;p&gt;Package the verifier as a required dependency for update support. If it cannot initialize, turn off updating and report a clear error. Pin the expected public key or trust root, validate the signature over the exact bytes later consumed, and reject missing, malformed, expired, or wrong-signer results. Do not catch verification errors and continue.&lt;/p&gt;&#10;&lt;p&gt;After verification, extract into a new temporary directory with &lt;code&gt;filter=&amp;quot;data&amp;quot;&lt;/code&gt;. Reject links if they are unnecessary, limit member count and total expanded size, allow only expected file types, and publish the completed tree atomically. Run the updater with the least filesystem privilege it needs.&lt;/p&gt;&#10;&lt;p&gt;Regression tests should cover a valid signature, an invalid signature, a missing signature, an unavailable verifier, a corrupt archive, a traversal member, an escaping link, and an oversized archive. Assert that no file outside the staging directory changes on every failure.&lt;/p&gt;&#10;&lt;p&gt;Audit every &lt;code&gt;if crypto_library:&lt;/code&gt; branch as if the dependency were absent, because eventually it will be.&lt;/p&gt;&#10;</description></item><item><title>Use public SDK integration tests to find API coverage questions</title><link>https://eghuro.cz/posts/sdk-integration-tests-coverage-gaps/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/sdk-integration-tests-coverage-gaps/</guid><description>&lt;p&gt;A well-tested API can still leave you unsure where to investigate next. Compare its exposed operations with the vendor&amp;rsquo;s public SDK integration tests, then inspect what those tests actually assert. The result is a list of coverage questions you can investigate within an authorized scope.&lt;/p&gt;&#10;&lt;p&gt;The starting assumption in one review was that the system was well tested, so ordinary scenarios were unlikely to reveal much. Reading the SDK tests changed the next question: which behaviors did this particular suite demonstrate? That comparison produced candidate leads. It did not provide a complete coverage audit or confirm a vulnerability.&lt;/p&gt;&#10;&#10;&lt;h2 id="build-an-operation-inventory-before-searching-tests"&gt;Build an operation inventory before searching tests &lt;/h2&gt;&#10;&lt;p&gt;Start with the documented API operations available to the account or environment under review. Record the API version and the SDK release or commit beside that inventory. An operation introduced after the SDK release should not be counted as an unexplained omission.&lt;/p&gt;&#10;&lt;p&gt;Map each operation to SDK methods and then to integration tests. Follow wrappers and shared fixtures; a test name does not have to match an operation name. Keep unresolved mappings marked as unknown.&lt;/p&gt;&#10;&lt;p&gt;Text searches help locate candidates, but they do not measure runtime coverage. A matching method name might appear only in setup, a comment, or an assertion about a mock. A missing name might be hidden behind a helper or parameterized test. Check whether each test calls the service, what inputs it supplies, and which results it verifies.&lt;/p&gt;&#10;&#10;&lt;h2 id="record-assertions-not-just-test-presence"&gt;Record assertions, not just test presence &lt;/h2&gt;&#10;&lt;p&gt;This clean-room fixture is invented. Its operation names and test behavior do not describe any real service:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Operation&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Evidence in the fictional public suite&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Next coverage question&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;create_item&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Creates an item and asserts that an ID exists&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Does an invalid field produce the documented rejection?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;update_item&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Updates an item owned by the test account and reads it back&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Does a different test account lack permission to update it?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;get_item&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No direct test located; helper usage unresolved&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Does a shared fixture exercise retrieval and assert its result?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The last row stays unresolved until someone reads the helper. It would be premature to label it &amp;ldquo;untested.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;For each mapped test, note its credentials&amp;rsquo; role without recording secrets, fixture ownership, expected failure behavior, and assertions. A successful request under one account says little about authorization under another. A negative test that merely catches any exception may pass on a network failure without demonstrating the intended denial.&lt;/p&gt;&#10;&lt;p&gt;Use precise notes such as &amp;ldquo;no assertion for this behavior found in the inspected public suite.&amp;rdquo; Vendors may have private service tests, separate security suites, or tests in another SDK. Public test omissions cannot prove those checks are absent.&lt;/p&gt;&#10;&#10;&lt;h2 id="follow-the-data-to-its-consumer"&gt;Follow the data to its consumer &lt;/h2&gt;&#10;&lt;p&gt;A coverage gap can justify a closer look at an input field if an API accepts and returns a string, which proves only acceptance and retrieval. Storage may legitimately preserve arbitrary text.&lt;/p&gt;&#10;&lt;p&gt;For a rendering-related hypothesis, maintain a small dataflow record: input field, stored representation, consuming component, output context, and observed handling. The consumer determines whether it treats the string safely. An API response alone cannot demonstrate unsafe rendering or a security impact.&lt;/p&gt;&#10;&lt;p&gt;Before any live follow-up, confirm the allowed scope, test accounts, and rate limits; use owned test data and the least disruptive check that answers the question.&lt;/p&gt;&#10;&lt;p&gt;If the consumer cannot be inspected or exercised within that scope, leave the hypothesis unresolved. Do not turn a stored value into a vulnerability claim to make the investigation sound complete.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-the-map-before-choosing-a-lead"&gt;Verify the map before choosing a lead &lt;/h2&gt;&#10;&lt;p&gt;Have another pass through the fixtures and shared helpers, reconcile SDK and API versions, and separate source inspection from tests actually executed. For every proposed follow-up, write down the missing assertion and the observation that would settle it.&lt;/p&gt;&#10;&lt;p&gt;Pick one unresolved row in your own operation map and trace it through the test&amp;rsquo;s assertions before sending another request.&lt;/p&gt;&#10;</description></item><item><title>Google Drive drive.file + Picker: picking a folder grants the folder, not its contents</title><link>https://eghuro.cz/posts/google-drive-file-picker-folder-empty-list/</link><pubDate>Sun, 19 Apr 2026 00:17:00 +0200</pubDate><guid>https://eghuro.cz/posts/google-drive-file-picker-folder-empty-list/</guid><description>&lt;p&gt;A web app lets the user pick a Google Drive folder in the Google Picker, then lists that folder from the server with the Drive API v3. The folder was full of files. The listing came back like this, every time:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;#34;files&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:[]}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The status was 200, with no 403 and nothing in the response that mentioned permissions.&lt;/p&gt;&#10;&lt;p&gt;If you request the &lt;code&gt;drive.file&lt;/code&gt; scope and let users pick a folder, this is expected: the grant covers the folder itself and none of the files inside it.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;The browser gets an OAuth access token with the &lt;code&gt;drive.file&lt;/code&gt; scope and opens the Picker on a folder view:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DocsView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ViewId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOLDERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setMimeTypes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;application/vnd.google-apps.folder&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setSelectFolderEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setIncludeFolders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;picker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PickerBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setOAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setDeveloperKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onPicked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The callback sends the picked folder ID to the server, which walks the folder with &lt;code&gt;files.list&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;https://www.googleapis.com/drive/v3/files&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;q&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;folderId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;&amp;#39; in parents and trashed = false`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;fields&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;nextPageToken, files(id, name, mimeType, size)&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;supportsAllDrives&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;true&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;includeItemsFromAllDrives&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;true&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;drive.file&lt;/code&gt; looks like the obvious scope here. The &lt;a&#10; href="https://developers.google.com/workspace/drive/picker/guides/web-picker"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Picker guide&lt;/a&gt;&#10; tells you to use it, and Google&amp;rsquo;s &lt;a&#10; href="https://developers.google.com/workspace/drive/api/guides/api-specific-auth"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;scope list&lt;/a&gt;&#10; marks it non-sensitive, so a public app needs only basic verification. The app only ever reads folders the user picked, which sounds like exactly what the scope is for.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-dead-end-setappid"&gt;The dead end: setAppId &lt;/h2&gt;&#10;&lt;p&gt;The first suspect was &lt;code&gt;PickerBuilder.setAppId()&lt;/code&gt;. The setup above doesn&amp;rsquo;t call it, and the Picker guide says to &amp;ldquo;set the Drive App ID using the Cloud project number to allow the app to access the user&amp;rsquo;s files&amp;rdquo;. The theory was that without an app ID, the grant belongs to the access token that was live at pick time, so the server&amp;rsquo;s later call with a refreshed token sees nothing.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setAppId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLOUD_PROJECT_NUMBER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// the numeric project number, not the project ID&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you stay on &lt;code&gt;drive.file&lt;/code&gt;, set it, because the guide asks for it. It didn&amp;rsquo;t change the result here. The listing was still &lt;code&gt;{&amp;quot;files&amp;quot;:[]}&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="whats-actually-going-on"&gt;What&amp;rsquo;s actually going on &lt;/h2&gt;&#10;&lt;p&gt;To see where the files went, the server logged the raw body of every &lt;code&gt;files.list&lt;/code&gt; response. A truly empty folder and a folder the app can&amp;rsquo;t see into give the same answer: status 200 and &lt;code&gt;&amp;quot;files&amp;quot;: []&lt;/code&gt;. The app only had access to the selected folder. Its children were never shared, so Drive left them out of the result and didn&amp;rsquo;t report an error.&lt;/p&gt;&#10;&lt;p&gt;The scope&amp;rsquo;s description says this once you read it with folders in mind. &lt;code&gt;drive.file&lt;/code&gt; covers files &amp;ldquo;that you open with an app or that the user shares with an app&amp;rdquo; — picking a folder shares one item: the folder. The docs never say the grant extends to its contents, and it doesn&amp;rsquo;t.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Pick the scope that matches what the server does. A server that reads everything under the picked folder can&amp;rsquo;t do that with &lt;code&gt;drive.file&lt;/code&gt;, so the app switched to &lt;code&gt;drive.readonly&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DRIVE_SCOPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;https://www.googleapis.com/auth/drive.readonly&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It kept the Picker for choosing the folder and removed the &lt;code&gt;setAppId&lt;/code&gt; call, which &lt;code&gt;drive.readonly&lt;/code&gt; doesn&amp;rsquo;t need.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;drive.readonly&lt;/code&gt; is a &lt;a&#10; href="https://developers.google.com/workspace/drive/api/guides/api-specific-auth"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;restricted scope&lt;/a&gt;&#10;, so a public app needs Google&amp;rsquo;s restricted-scope verification to use it. The app in this story was internal to one Google Workspace domain, so verification didn&amp;rsquo;t apply.&lt;/p&gt;&#10;&lt;p&gt;Update the consent text in your own UI as well. &amp;ldquo;The app only sees folders you pick&amp;rdquo; was true under &lt;code&gt;drive.file&lt;/code&gt; and false after the switch. The new text says the app has read-only access to the whole Drive and downloads only from the folders the user picks.&lt;/p&gt;&#10;&lt;p&gt;Two other ways out may fit your constraints better. If users can pick files instead of a folder, enable multi-select in the Picker: every picked file is shared with the app, and &lt;code&gt;drive.file&lt;/code&gt; covers it. Or use a service account and have users share the folder with its address, which moves the access decision into Drive&amp;rsquo;s own sharing dialog. We ruled that out here for organizational reasons.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Under &lt;code&gt;drive.file&lt;/code&gt;, the Picker shares exactly the items the user selects. A folder is one item, and its children aren&amp;rsquo;t part of the grant, so listing it returns an empty &lt;code&gt;files&lt;/code&gt; array with status 200.&lt;/p&gt;&#10;&lt;p&gt;If your app requests &lt;code&gt;drive.file&lt;/code&gt; and calls &lt;code&gt;setSelectFolderEnabled(true)&lt;/code&gt;, list a folder you know isn&amp;rsquo;t empty from the server and log the raw response before you ship.&lt;/p&gt;&#10;</description></item><item><title>rdflib field notes: ns.format matches nothing, and parse() goes online</title><link>https://eghuro.cz/posts/linked-data-in-python-field-notes/</link><pubDate>Sat, 11 Mar 2023 12:37:00 +0100</pubDate><guid>https://eghuro.cz/posts/linked-data-in-python-field-notes/</guid><description>&lt;p&gt;UUpdate 2026-11-08: retested on rdflib 7.6.0 and added the &lt;code&gt;ConjunctiveGraph&lt;/code&gt; deprecation, pyoxigraph, and the RDF 1.2 and SPARQL 1.2 status.&lt;/p&gt;&#10;&lt;p&gt;This lookup returns an empty list, with no exception and no warning:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;rdflib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URIRef&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DCT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Namespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;http://purl.org/dc/terms/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;URIRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;http://example.org/doc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DCT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;format&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;text/csv&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DCT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# []&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DCT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;format&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="c1"&gt;# [rdflib.term.Literal(&amp;#39;text/csv&amp;#39;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Namespace&lt;/code&gt; subclasses &lt;code&gt;str&lt;/code&gt;, so &lt;code&gt;DCT.format&lt;/code&gt; is the bound method &lt;code&gt;str.format&lt;/code&gt;, and a lookup on it matches nothing. RDF code tends to fail this way: you get fewer triples, and that looks exactly like sparse data.&lt;/p&gt;&#10;&lt;p&gt;I collected these while re-reading a crawler I wrote in early 2023, and rebuilt each as a minimal repro on rdflib 6.3.2 and 7.6.0 under Python 3.11. Both versions behave the same unless noted. They show what the code does. I have no production logs that say what any of them cost.&lt;/p&gt;&#10;&#10;&lt;h2 id="use-nsformat-for-names-that-str-already-owns"&gt;Use &lt;code&gt;ns[&amp;quot;format&amp;quot;]&lt;/code&gt; for names that &lt;code&gt;str&lt;/code&gt; already owns &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;format&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;index&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt;, and every other &lt;code&gt;str&lt;/code&gt; method name resolve to the method. &lt;code&gt;title&lt;/code&gt; is the exception, special-cased to return a &lt;code&gt;URIRef&lt;/code&gt;. Writes are safer than reads here: &lt;code&gt;g.add()&lt;/code&gt; raises &lt;code&gt;AssertionError&lt;/code&gt; on the method object.&lt;/p&gt;&#10;&lt;p&gt;For published vocabularies, import from &lt;code&gt;rdflib.namespace&lt;/code&gt;. &lt;code&gt;DCTERMS.format&lt;/code&gt; is a real &lt;code&gt;URIRef&lt;/code&gt; and &lt;code&gt;DCTERMS.nonsense&lt;/code&gt; raises &lt;code&gt;AttributeError&lt;/code&gt;. For your own vocabulary, &lt;code&gt;ClosedNamespace&lt;/code&gt; rejects unknown terms, but &lt;code&gt;.format&lt;/code&gt; on it is still the &lt;code&gt;str&lt;/code&gt; method, so keep the brackets.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-typo-in-the-base-iri-is-still-a-valid-iri"&gt;A typo in the base IRI is still a valid IRI &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Namespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;https://example.org/vocab/mediaTyp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mediaType&lt;/span&gt; &lt;span class="c1"&gt;# https://example.org/vocab/mediaTypmediaType&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My crawler shipped this. The branch using it asked for a predicate no document contains. &lt;code&gt;ClosedNamespace&lt;/code&gt; produces the same glued IRI, because it validates the term and has no opinion on the base. A one-line test covers it, with an allowlist for the rare namespace that ends differently:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&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;NS&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;#/&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="a-failed-parse-keeps-what-it-already-read"&gt;A failed parse keeps what it already read &lt;/h2&gt;&#10;&lt;p&gt;Feed &lt;code&gt;Graph.parse()&lt;/code&gt; five good N-Triples lines and one bad one. It raises &lt;code&gt;ParserError&lt;/code&gt;, and &lt;code&gt;len(g)&lt;/code&gt; is 5. Turtle does the same with &lt;code&gt;BadSyntax&lt;/code&gt;. If you parse into a shared graph, a truncated download leaves half a document behind.&lt;/p&gt;&#10;&lt;p&gt;Give each document its own store and merge only after the parse succeeds:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;tempfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;rdflib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URIRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plugin&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;rdflib.store&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&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;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;graph&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;LevelDB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;URIRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;URIRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;urn:doc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&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;create&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With a bad document, this raises an exception and &lt;code&gt;target&lt;/code&gt; stays empty. The disk store also caps memory. Loading 200,000 synthetic triples (14 MB of N-Triples, one run on Apple Silicon, rdflib 6.3.2, 30 MB baseline after imports):&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Store&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Time&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Peak RSS&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Disk&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;rdflib &lt;code&gt;Memory&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;2.6 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;351 MB&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;rdflib-leveldb&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;15.3 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;85 MB&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;39 MB&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;pyoxigraph 0.5.13 &lt;code&gt;bulk_load&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0.5 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;200 MB&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;10 MB&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Read that as a rough shape and benchmark your own data. &lt;a&#10; href="https://github.com/RDFLib/rdflib-leveldb"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;rdflib-leveldb&lt;/code&gt;&lt;/a&gt;&#10; isn&amp;rsquo;t on PyPI, so install it from git. Its last commit is from December 2021, though the snippet above still passes on 7.6.0. On macOS, I used &lt;code&gt;plyvel-ci&lt;/code&gt; because &lt;code&gt;plyvel&lt;/code&gt; has no Mac wheels. Pyoxigraph has its own API and isn&amp;rsquo;t a drop-in rdflib store.&lt;/p&gt;&#10;&lt;p&gt;A disk store bounds the triples and leaves the input alone. The Turtle parser calls &lt;code&gt;stream.read()&lt;/code&gt; on the whole source, with the comment &lt;code&gt;# Not ideal&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="parse-can-go-online"&gt;&lt;code&gt;parse()&lt;/code&gt; can go online &lt;/h2&gt;&#10;&lt;p&gt;Parsing JSON-LD fetches remote &lt;code&gt;@context&lt;/code&gt; values. The &lt;a&#10; href="https://rdflib.readthedocs.io/en/stable/security_considerations"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RDFLib security page&lt;/a&gt;&#10; says so. Direct cycles are caught (&lt;code&gt;recursive context inclusion&lt;/code&gt; after 2 requests). A chain of contexts that each point to a new one isn&amp;rsquo;t: with Python&amp;rsquo;s default recursion limit, one small document cost 967 requests to my local server before &lt;code&gt;RecursionError&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Block the network during the parse step:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;urllib.error&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;urllib.request&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NoNetwork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&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;BaseHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;handler_order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="c1"&gt;# before the stock HTTPHandler (500)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;http_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;blocked: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;https_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http_open&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&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;install_opener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="o"&gt;.&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;build_opener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NoNetwork&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Leave out &lt;code&gt;handler_order&lt;/code&gt; and the stock handler answers first, so the request goes through. My first attempt did exactly that. The opener is process-global, and the RDFLib docs rank OS-level isolation such as containers above Python-level controls, so use it in addition to those on untrusted input.&lt;/p&gt;&#10;&lt;p&gt;Then make the following links a separate, bounded step that you control:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crawl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="n"&gt;seed&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;iri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;popleft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;references&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# fetch, parse in isolation, return IRIs to follow&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On a graph with a cycle and a chain, this visits each IRI once and stops at depth 3.&lt;/p&gt;&#10;&#10;&lt;h2 id="keep-a-workarounds-reason-in-a-test"&gt;Keep a workaround&amp;rsquo;s reason in a test &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://github.com/RDFLib/rdflib/issues/1195"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;rdflib #1195&lt;/a&gt;&#10; reported that a CONSTRUCT response with &lt;code&gt;application/rdf+xml; charset=UTF-8&lt;/code&gt; broke the plugin lookup. My code registered that literal string as a parser name and pointed a comment at the issue.&lt;/p&gt;&#10;&lt;p&gt;Against a fake endpoint sending that header, rdflib 5.0.0 raises &lt;code&gt;PluginException&lt;/code&gt; and 6.0.0 works without the workaround (both on Python 3.9). My pin was already 6.0.0 in the commit that added it, so on this path it never changed, and it stayed that way through 6.3.2. Watching the issue wouldn&amp;rsquo;t have helped: it closed in August 2022 on a PR that only added tests.&lt;/p&gt;&#10;&lt;p&gt;A test that exercises the upstream behavior with the workaround off tells you the day it can go. &lt;code&gt;plugin.get()&lt;/code&gt; itself still rejects that string on 7.6.0, so strip parameters if you call it directly.&lt;/p&gt;&#10;&#10;&lt;h2 id="dataset-isnt-a-drop-in-for-conjunctivegraph"&gt;&lt;code&gt;Dataset&lt;/code&gt; isn&amp;rsquo;t a drop-in for &lt;code&gt;ConjunctiveGraph&lt;/code&gt; &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;ConjunctiveGraph&lt;/code&gt; raises a &lt;code&gt;DeprecationWarning&lt;/code&gt; on 7.6.0 and the replacement is &lt;code&gt;Dataset&lt;/code&gt;, which has a different default. With one quad in a named graph:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Class&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;&lt;code&gt;triples()&lt;/code&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;&lt;code&gt;SELECT ?s ?p ?o&lt;/code&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ConjunctiveGraph()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Dataset()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Dataset(default_union=True)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Migrate with &lt;code&gt;default_union=True&lt;/code&gt;, unless you want queries limited to the default graph.&lt;/p&gt;&#10;&lt;p&gt;On standards, as of November 2026: &lt;a&#10; href="https://www.w3.org/TR/rdf12-concepts"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RDF 1.2 Concepts&lt;/a&gt;&#10; is a Candidate Recommendation and &lt;a&#10; href="https://www.w3.org/TR/sparql12-query"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;SPARQL 1.2 Query&lt;/a&gt;&#10; is a Working Draft, so SPARQL 1.1 remains the Recommendation.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Most of these bugs arrive as an empty result, and nothing downstream complains. For each predicate your pipeline depends on, keep a fixture with one known triple and assert that your code finds it.&lt;/p&gt;&#10;&lt;p&gt;Start with this search for unbracketed &lt;code&gt;str&lt;/code&gt; names in your own code:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -rnE &lt;span class="s1"&gt;&amp;#39;\b[A-Za-z_]+\.(format|count|index)\b([^(]|$)&amp;#39;&lt;/span&gt; --include&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;*.py&amp;#39;&lt;/span&gt; .&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</description></item><item><title>Celery ignored task_task_routes: silent failures from growing a crawler on Redis</title><link>https://eghuro.cz/posts/growing-a-crawler-on-celery/</link><pubDate>Sat, 11 Mar 2023 12:19:00 +0100</pubDate><guid>https://eghuro.cz/posts/growing-a-crawler-on-celery/</guid><description>&lt;p&gt;Update 2026-11-08: rebuilt every case on Celery 5.2.7 and 5.7.0.&lt;/p&gt;&#10;&lt;p&gt;This sat in my crawler&amp;rsquo;s Celery config:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;task_task_routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tasks.urgent&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;queue&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;celery&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tasks.post&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;queue&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;celery:2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The setting is called &lt;code&gt;task_routes&lt;/code&gt;. Celery doesn&amp;rsquo;t check setting names, so the app started normally, &lt;code&gt;app.conf.task_routes&lt;/code&gt; stayed &lt;code&gt;None&lt;/code&gt;, and three different tasks all landed in the default queue:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;{&amp;#39;celery:1&amp;#39;: 3}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That typo arrived with the commit that moved the crawler to Redis priority queues, and it wasn&amp;rsquo;t the only failure from that stretch that raised nothing. Two small changes catch most of them: compare your settings against Celery&amp;rsquo;s list of known names, and set &lt;code&gt;task_create_missing_queues = False&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;I found these by rereading the code and rebuilt each one against a private Redis, on Python 3.11 with the locked versions (Celery 5.2.7, kombu 5.2.4, redis-py 4.5.4) and on Celery 5.7.0. I have no production logs from that time, so this shows what the code does and leaves open what it cost.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-the-crawler-got-here"&gt;How the crawler got here &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Date&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Change&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2018&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Celery on Redis, with the crawl state in Redis too&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2020&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mongo for reports; separate priority and regular tasks&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2021-09&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A monitor task that decides when the crawl is done&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2023-02-03&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Large structures move from Redis to SQLite, then to Postgres the same night&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2023-02-07&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mongo dropped&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2023-02-10&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Bulk inserts from generators&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2023-02-12&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Named priority queues; each input parsed into an on-disk graph in a temp directory&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Redis ended up mostly as the broker and a few caches. The traps below surfaced in the 2023 part of that table.&lt;/p&gt;&#10;&#10;&lt;h2 id="celery-ignores-settings-it-doesnt-know"&gt;Celery ignores settings it doesn&amp;rsquo;t know &lt;/h2&gt;&#10;&lt;p&gt;A test finds the typo. &lt;code&gt;DEFAULTS&lt;/code&gt; holds every setting name Celery recognizes:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ModuleType&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;celery.app.defaults&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DEFAULTS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;celeryconfig&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_celery_settings_are_known&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&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;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;celeryconfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;islower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;_&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ModuleType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DEFAULTS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On my config, it reports &lt;code&gt;{'task_task_routes'}&lt;/code&gt;, on both Celery versions.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-queue-with-no-consumer-looks-like-a-finished-crawl"&gt;A queue with no consumer looks like a finished crawl &lt;/h2&gt;&#10;&lt;p&gt;The same commit changed what the worker reads, and the post-processing chain kept an old queue name:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;celery -A app worker -Q celery,celery:1,celery:2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;step_two&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;query&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;task_create_missing_queues&lt;/code&gt; is on by default, so sending to &lt;code&gt;query&lt;/code&gt; creates it. In the rebuild, the message sat in that key, and no step ran. Declare your queues and turn creation off:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;kombu&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Queue&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;task_create_missing_queues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;task_queues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;celery&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bulk&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;celery.exceptions.QueueNotFound: &amp;#34;Queue &amp;#39;query&amp;#39; missing from task_queues&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That protects the sending side only. The &lt;code&gt;-Q&lt;/code&gt; list on the worker is still a separate copy, so build it from the same list.&lt;/p&gt;&#10;&#10;&lt;h2 id="redis-priority-steps-and-queue-names-share-a-namespace"&gt;Redis priority steps and queue names share a namespace &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;broker_transport_options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;priority_steps&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;sep&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;queue_order_strategy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;priority&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With these options, kombu stores priority 1 of queue &lt;code&gt;celery&lt;/code&gt; queue under the Redis key &lt;code&gt;celery:1&lt;/code&gt;. I also named a queue &lt;code&gt;celery:1&lt;/code&gt;. A message sent with &lt;code&gt;queue=&amp;quot;celery&amp;quot;, priority=1&lt;/code&gt; showed up in that key. Priority 0 gets no suffix, so &lt;code&gt;celery:0&lt;/code&gt; never exists.&lt;/p&gt;&#10;&lt;p&gt;Pick one scheme: a single queue with &lt;code&gt;priority=&lt;/code&gt;, or named queues whose names don&amp;rsquo;t contain the separator.&lt;/p&gt;&#10;&#10;&lt;h2 id="llen-0-doesnt-mean-the-crawl-is-done"&gt;&lt;code&gt;LLEN&lt;/code&gt; 0 doesn&amp;rsquo;t mean the crawl is done &lt;/h2&gt;&#10;&lt;p&gt;Every ten minutes, the monitor computed this and declared the crawl done at zero:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;enqueued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;red&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;celery&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;red&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;celery:0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;red&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;celery:1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It counts the key that never exists and skips &lt;code&gt;celery:2&lt;/code&gt;. Even with the right keys, a list length misses work:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;State&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Monitor&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Lists + &lt;code&gt;HLEN unacked&lt;/code&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Task waiting in &lt;code&gt;celery:2&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Task running, &lt;code&gt;task_acks_late=True&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retry waiting on a 6 s countdown&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Chain stranded in &lt;code&gt;query&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;outstanding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queues&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;celery&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;celery:1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;celery:2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;unacked&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;unacked&lt;/code&gt; is kombu&amp;rsquo;s internal bookkeeping hash, and it shows running tasks only because my config sets &lt;code&gt;task_acks_late=True&lt;/code&gt;. With early acknowledgment, a running task has already left it. The last row shows the limit of any count: it can&amp;rsquo;t see a queue you forgot to list.&lt;/p&gt;&#10;&lt;p&gt;When you know the fan-out up front, a chord gives a real completion signal. &lt;a&#10; href="https://docs.celeryq.dev/en/stable/userguide/canvas.html#chords"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;The docs&lt;/a&gt;&#10; say members must not ignore their results, and I copied that warning into a comment. On the Redis backend, a chord of &lt;code&gt;ignore_result=True&lt;/code&gt; members still ran their bodies with all three values, on both versions. I&amp;rsquo;d follow the docs anyway, since I tested one backend. My tasks also spawned more tasks, which a chord can&amp;rsquo;t express.&lt;/p&gt;&#10;&#10;&lt;h2 id="decode_responsestrue-on-a-shared-pool-breaks-a-binary-cache"&gt;&lt;code&gt;decode_responses=True&lt;/code&gt; on a shared pool breaks a binary cache &lt;/h2&gt;&#10;&lt;p&gt;In 2021, my HTTP cache shared the application&amp;rsquo;s Redis pool, which decoded responses to strings. requests-cache stores pickles, and a pickle starts with byte &lt;code&gt;0x80&lt;/code&gt;. Two identical GETs:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;requests-cache&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Decoding pool&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Separate pool&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0.9.8&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2 origin hits, one log line&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1 origin hit&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1.3.3&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Second GET raises &lt;code&gt;UnicodeDecodeError&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1 origin hit&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The log line on the old version is all you get:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unable to deserialize response with key 53630720a0b4bd02: &amp;#39;utf-8&amp;#39; codec can&amp;#39;t decode byte 0x80 in position 0: invalid start byte&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The commit that fixed it says what to do: &amp;ldquo;a separate Redis pool without decoding&amp;rdquo;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;app_pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionPool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_url&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;decode_responses&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;cache_pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionPool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_url&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="execute-drains-a-generator-group-doesnt"&gt;&lt;code&gt;execute()&lt;/code&gt; drains a generator, &lt;code&gt;group()&lt;/code&gt; doesn&amp;rsquo;t &lt;/h2&gt;&#10;&lt;p&gt;I passed generators to &lt;code&gt;session.execute(insert(Row), rows())&lt;/code&gt; to save memory. SQLAlchemy reads them to the end first: on 2.0.7 and 2.1.8, with a SQLite engine, the generator had yielded 5,000 of 5,000 rows before the first &lt;code&gt;INSERT&lt;/code&gt;. Batch it yourself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;itertools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;islice&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;islice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With 2,500 rows, the three &lt;code&gt;INSERT&lt;/code&gt;s fired after 1,000, 2,000, and 2,500 yields.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;group(generator).apply_async()&lt;/code&gt; works the other way and publishes as it iterates. With a generator that yielded two signatures and then raised, one task was already in Redis, and the caller got the exception with no &lt;code&gt;GroupResult&lt;/code&gt;. Make those tasks safe to run twice. I still prefer &lt;code&gt;group&lt;/code&gt; to &lt;code&gt;chunks&lt;/code&gt;, which &lt;a&#10; href="https://eghuro.cz/posts/harvesting-sparql-endpoints-politely"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;loses retries&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Most of this was introduced or fixed during ten days of refactoring in February 2023, and almost none of it raised an error then. A queue migration needs a written list of each queue, who sends to it, who reads it, and what counts it.&lt;/p&gt;&#10;&lt;p&gt;Try the settings test above on your own Celery config.&lt;/p&gt;&#10;</description></item><item><title>Celery time_limit on the gevent pool: a 1 s limit let a CPU task run 3 s</title><link>https://eghuro.cz/posts/gevent-timeouts-do-not-time-out/</link><pubDate>Wed, 08 Feb 2023 23:36:00 +0100</pubDate><guid>https://eghuro.cz/posts/gevent-timeouts-do-not-time-out/</guid><description>&lt;p&gt;Update 2026-11-08: rebuilt every case on Celery 5.2.7 with gevent 22.10.2, and on Celery 5.7.0 with gevent 26.9.0.&lt;/p&gt;&#10;&lt;p&gt;I gave a Celery task &lt;code&gt;time_limit=1&lt;/code&gt; and ran it with &lt;code&gt;-P gevent&lt;/code&gt;. The CPU-bound task kept going for 3 s, finished all its work, and was then reported as failed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;state=FAILURE result=TimeLimitExceeded(1) got_after=3.06s&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The gevent pool enforces the limit with &lt;code&gt;gevent.Timeout&lt;/code&gt;, which can only fire when the task yields to the event loop. CPU-bound code never does. Keep gevent for I/O and run CPU work on a prefork worker.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;workers guide&lt;/a&gt;&#10; says this in two sentences: the gevent pool &amp;ldquo;does not implement soft time limits&amp;rdquo; and &amp;ldquo;will not enforce the hard time limit if the task is blocking&amp;rdquo;. It leaves out what that looks like in a task, and how an exception handler makes it worse.&lt;/p&gt;&#10;&lt;p&gt;I found this by rereading old code and rebuilt each case on a real worker with a private Redis, on Python 3.11. Both version pairs behaved the same. I have no production logs from that time.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-a-1-s-limit-does-on-each-pool"&gt;What a 1 s limit does on each pool &lt;/h2&gt;&#10;&lt;p&gt;Each task does 3 s of work:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Task, 1 s limit&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;gevent&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;prefork&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;CPU loop&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Ran the full 3 s, then &lt;code&gt;TimeLimitExceeded&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stopped after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;time.sleep&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stopped after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stopped after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sleep inside &lt;code&gt;try&lt;/code&gt; / &lt;code&gt;except Exception&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Handler skipped, stopped after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stopped after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sleep inside &lt;code&gt;try&lt;/code&gt; / bare &lt;code&gt;except:&lt;/code&gt;, then 3 s more&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SUCCESS&lt;/code&gt; at 4.01 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;FAILURE&lt;/code&gt; after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;soft_time_limit=1&lt;/code&gt; with sleep&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Ignored, &lt;code&gt;SUCCESS&lt;/code&gt; at 3.01 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SoftTimeLimitExceeded&lt;/code&gt; after about 1 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The first row is the nasty one. The work completed, its side effects happened, and the task still reports failure. The timer was pending the whole time and fired at the first switch after the loop.&lt;/p&gt;&#10;&lt;p&gt;A CPU-bound task also stalls everything else in that worker process. With &lt;code&gt;-c 8&lt;/code&gt;, a task sent during a 3 s CPU loop waited 2.8 s to start, against a few milliseconds on an idle worker. On prefork with &lt;code&gt;-c 2&lt;/code&gt;, it still started within a few milliseconds.&lt;/p&gt;&#10;&#10;&lt;h2 id="split-fetch-and-parse-into-separate-workers"&gt;Split fetch and parse into separate workers &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task_routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tasks.fetch&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;queue&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;fetch&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tasks.parse&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;queue&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;parse&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&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;parse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply_async&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;celery -A app worker -n fetch@%h -P gevent -c &lt;span class="m"&gt;60&lt;/span&gt; -Q fetch&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;celery -A app worker -n parse@%h -P prefork -c &lt;span class="m"&gt;2&lt;/span&gt; -Q parse&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;time_limit=1&lt;/code&gt; on &lt;code&gt;parse&lt;/code&gt;, a chain with a 3 s CPU parse ended in &lt;code&gt;TimeLimitExceeded&lt;/code&gt; after 1.1 s, and a 0.1 s parse succeeded. Pass a file path or an object-store key between the two rather than the body.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-a-timeout-cant-interrupt-cpu-work"&gt;Why a timeout can&amp;rsquo;t interrupt CPU work &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;celery/concurrency/gevent.py&lt;/code&gt; in 5.7.0 wraps each task like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;apply_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;accept_callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;getpid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;propagate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;timeout_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Timeout&lt;/code&gt; is a timer on the event loop, and everything runs in one thread. The loop gets control only when the current greenlet yields, typically at a monkey-patched socket call or sleep. Prefork stops a task from outside, and the hard limit replaces the child process.&lt;/p&gt;&#10;&lt;p&gt;The soft limit is simpler: &lt;code&gt;on_apply&lt;/code&gt; takes &lt;code&gt;soft_timeout&lt;/code&gt; through &lt;code&gt;**_&lt;/code&gt; and never uses it. The worker log says nothing about that.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-bare-except-cancels-the-limit"&gt;A bare &lt;code&gt;except:&lt;/code&gt; cancels the limit &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://www.gevent.org/api/gevent.timeout.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;gevent.Timeout&lt;/code&gt; derives from &lt;code&gt;BaseException&lt;/code&gt;&lt;/a&gt;&#10;, so &lt;code&gt;except Exception&lt;/code&gt; lets it pass and a bare &lt;code&gt;except:&lt;/code&gt; catches it. My code had a fallback like this several calls below the task:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lookup&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;lookup failed, trying the fallback&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The timer fires once. After the handler ate it, the task had no limit at all, and in the rebuild it finished three seconds past the deadline as &lt;code&gt;SUCCESS&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;My fix at the time, a commit called &amp;ldquo;Fail the task on timeout&amp;rdquo;, added &lt;code&gt;except Timeout&lt;/code&gt; at the top of the task. A &lt;code&gt;Timeout&lt;/code&gt; stops at the nearest handler that matches, so the nested bare &lt;code&gt;except:&lt;/code&gt; on the fallback path kept swallowing it. Change the nested handler:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;lookup failed, trying the fallback&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you need cleanup, catch &lt;code&gt;Timeout&lt;/code&gt; and re-raise it. In the rebuild, the cleanup ran at 1.01 s, and Celery recorded &lt;code&gt;TimeLimitExceeded&lt;/code&gt;, so there was no need to set the state by hand.&lt;/p&gt;&#10;&lt;p&gt;The soft limit goes the other way on prefork: &lt;code&gt;SoftTimeLimitExceeded&lt;/code&gt; is an &lt;code&gt;Exception&lt;/code&gt;, so a task that moves between pools needs its handlers checked for both.&lt;/p&gt;&#10;&lt;p&gt;For a Python loop you own, &lt;code&gt;gevent.sleep(0)&lt;/code&gt; on each iteration gives the loop a turn, and the task then stopped at 1.01 s. That can&amp;rsquo;t help inside a library call that never yields.&lt;/p&gt;&#10;&lt;p&gt;The sibling trap on the same crawler: &lt;a&#10; href="https://eghuro.cz/posts/celery-retry-is-an-exception"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;&lt;code&gt;Retry&lt;/code&gt; is an &lt;code&gt;Exception&lt;/code&gt;, and &lt;code&gt;except Exception&lt;/code&gt; eats it&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On the gevent pool, &lt;code&gt;time_limit&lt;/code&gt; is only as good as the task&amp;rsquo;s willingness to yield, so treat it as a limit on I/O waits.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;grep -rnE --include='*.py' 'except:[[:space:]]*$' .&lt;/code&gt; on anything that runs under &lt;code&gt;-P gevent&lt;/code&gt;.&lt;/p&gt;&#10;</description></item><item><title>Celery self.retry(5) raises TypeError, and except Exception swallows the retry</title><link>https://eghuro.cz/posts/celery-retry-is-an-exception/</link><pubDate>Tue, 07 Feb 2023 00:01:00 +0100</pubDate><guid>https://eghuro.cz/posts/celery-retry-is-an-exception/</guid><description>&lt;p&gt;Update 2026-11-08: rebuilt every case on Celery 5.2.7 and 5.7.0.&lt;/p&gt;&#10;&lt;p&gt;My crawler called Celery&amp;rsquo;s &lt;code&gt;retry&lt;/code&gt; three different ways, and only one did what I meant:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Call&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Result&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;self.retry(5)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Task fails with the error below, no retry&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;self.retry(timeout=5)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retries in 180 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;self.retry(countdown=5)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retries in 5 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TypeError: &amp;#39;int&amp;#39; object is not iterable&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Even the correct call can go wrong. &lt;code&gt;retry()&lt;/code&gt; publishes the new message and then raises &lt;code&gt;celery.exceptions.Retry&lt;/code&gt;, an ordinary &lt;code&gt;Exception&lt;/code&gt;. An &lt;code&gt;except Exception&lt;/code&gt; around it marks the work as failed while the retry is already on its way. Pass &lt;code&gt;countdown=&lt;/code&gt; by keyword and let &lt;code&gt;TaskPredicate&lt;/code&gt; pass through your broad handlers.&lt;/p&gt;&#10;&lt;p&gt;I found these by rereading old code and rebuilt each case on a real worker with a private Redis as broker and result backend, on Python 3.11. Both Celery versions behaved the same. I have no production logs from that time, so this shows what the code does and leaves open what it cost.&lt;/p&gt;&#10;&#10;&lt;h2 id="re-raise-taskpredicate-before-except-exception"&gt;Re-raise &lt;code&gt;TaskPredicate&lt;/code&gt; before &lt;code&gt;except Exception&lt;/code&gt; &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;celery.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TaskPredicate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crawl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&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="c1"&gt;# calls self.retry(exc=err, countdown=1) on a network error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TaskPredicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mark_failed&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gave up&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mark_ok&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;done&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;TaskPredicate&lt;/code&gt; is the base class of &lt;code&gt;Retry&lt;/code&gt;, &lt;code&gt;Ignore&lt;/code&gt; and &lt;code&gt;Reject&lt;/code&gt;. With this handler, &lt;code&gt;result.get()&lt;/code&gt; returned &lt;code&gt;'done'&lt;/code&gt; about 1 s later, after the retry had run.&lt;/p&gt;&#10;&lt;p&gt;My handler already knew about one of the three:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Ignore&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Retry&lt;/code&gt; needed the same treatment and didn&amp;rsquo;t get it.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-three-calls-differ"&gt;Why the three calls differ &lt;/h2&gt;&#10;&lt;p&gt;The signature on both versions is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;throw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;countdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first positional parameter is the replacement &lt;code&gt;args&lt;/code&gt; for the retried task, so &lt;code&gt;5&lt;/code&gt; reaches &lt;code&gt;tuple(args)&lt;/code&gt; and raises before anything is published.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;timeout&lt;/code&gt; isn&amp;rsquo;t a parameter, so it lands in &lt;code&gt;**options&lt;/code&gt; and travels with the message. Spying on kombu&amp;rsquo;s &lt;code&gt;Producer.publish&lt;/code&gt; showed &lt;code&gt;timeout=5&lt;/code&gt; arriving there, where it limits how long publishing may take. The delay falls back to &lt;code&gt;default_retry_delay&lt;/code&gt;, &lt;a&#10; href="https://docs.celeryq.dev/en/stable/userguide/tasks.html#retrying"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;three minutes by default&lt;/a&gt;&#10;. Nothing raises. The only hint is the worker log:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Task tasks.crawl[62d0a257-...] retry: Retry in 180s&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="what-except-exception-does-to-a-retry"&gt;What &lt;code&gt;except Exception&lt;/code&gt; does to a retry &lt;/h2&gt;&#10;&lt;p&gt;Remove the &lt;code&gt;except TaskPredicate&lt;/code&gt; branch from the task above and let the first attempt hit a network error:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Time&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Event&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0.01 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retry published, &lt;code&gt;Retry&lt;/code&gt; caught, URL marked failed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0.01 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;result.get()&lt;/code&gt; returns &lt;code&gt;'gave up'&lt;/code&gt;, state &lt;code&gt;SUCCESS&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1.01 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retry runs and marks the URL ok&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;Later&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The backend still says &lt;code&gt;'gave up'&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The last row surprised me. On the Redis backend, &lt;code&gt;_store_result&lt;/code&gt; ignores any write once the stored state is &lt;code&gt;SUCCESS&lt;/code&gt;, so the retry&amp;rsquo;s own return value is dropped. Anyone reading the result sees the attempt that gave up.&lt;/p&gt;&#10;&lt;p&gt;Code review won&amp;rsquo;t catch this reliably, because the placement decides it. A &lt;code&gt;retry&lt;/code&gt; inside the &lt;code&gt;try&lt;/code&gt; body, even several calls deep, is caught. A &lt;code&gt;retry&lt;/code&gt; in a sibling &lt;code&gt;except&lt;/code&gt; clause is safe:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&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="c1"&gt;# a retry raised in here hits except Exception&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;RateLimited&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;countdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# sibling handlers don&amp;#39;t see this one&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mark_failed&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My code had both forms in the same function, so some retries worked. Writing &lt;code&gt;raise self.retry(...)&lt;/code&gt; changes nothing, and a bare &lt;code&gt;except:&lt;/code&gt; catches &lt;code&gt;Retry&lt;/code&gt; too. In an older module, a bare &lt;code&gt;except:&lt;/code&gt; also caught the &lt;code&gt;TypeError&lt;/code&gt; from &lt;code&gt;self.retry(5)&lt;/code&gt;, which recorded a rate-limited link as a broken one.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-dedupe-mark-written-first-makes-the-retry-skip-itself"&gt;A dedupe mark written first makes the retry skip itself &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crawl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sadd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;seen&amp;#34;&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="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;skipped&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;attempt 0 sadd=1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;attempt 1 sadd=0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The retry sees its own mark, returns &lt;code&gt;'skipped'&lt;/code&gt;, and the URL is never fetched. Combine that with the swallowed &lt;code&gt;Retry&lt;/code&gt; and the URL stays failed for good: in the rebuild, the final status was &lt;code&gt;failed&lt;/code&gt; and the result was &lt;code&gt;'gave up'&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Moving the &lt;code&gt;SADD&lt;/code&gt; after the work fixed it: the retry returned &lt;code&gt;'fetched'&lt;/code&gt;. Two concurrent tasks can then fetch the same URL, so the work has to be safe to repeat.&lt;/p&gt;&#10;&lt;p&gt;When the retry condition is just an exception type, &lt;code&gt;autoretry_for=(Flaky,), retry_kwargs={&amp;quot;countdown&amp;quot;: 1, &amp;quot;max_retries&amp;quot;: 2}&lt;/code&gt; saves calling &lt;code&gt;retry&lt;/code&gt; yourself. The exception has to leave the task body, so a broad handler inside it defeats this as well.&lt;/p&gt;&#10;&lt;p&gt;Time limits have a similar problem on the gevent pool, in the opposite direction: &lt;a&#10; href="https://eghuro.cz/posts/gevent-timeouts-do-not-time-out"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;&lt;code&gt;Timeout&lt;/code&gt; isn&amp;rsquo;t an &lt;code&gt;Exception&lt;/code&gt;&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;self.retry()&lt;/code&gt; is a &lt;code&gt;raise&lt;/code&gt; whose side effect has already happened, so any handler between it and the worker can make your bookkeeping disagree with the queue.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;grep -rn --include='*.py' '\.retry(' .&lt;/code&gt; on your tasks and check each hit for keyword arguments and an enclosing &lt;code&gt;except Exception&lt;/code&gt;.&lt;/p&gt;&#10;</description></item><item><title>Encrypt sensitive fields inside the ORM</title><link>https://eghuro.cz/posts/encrypt-sensitive-fields-inside-the-orm/</link><pubDate>Sat, 30 Oct 2021 18:03:45 +0200</pubDate><guid>https://eghuro.cz/posts/encrypt-sensitive-fields-inside-the-orm/</guid><description>&lt;p&gt;Your database may run where you don&amp;rsquo;t control the environment: embedded in a desktop app, or in the cloud for a web app. If it stores sensitive data, encrypt those fields so a leaked copy doesn&amp;rsquo;t expose them. In this article, I show how I did that in two projects, and the drop-in text field for Python ORMs that came out of it.&lt;/p&gt;&#10;&#10;&lt;h2 id="encrypted-fields-in-an-embedded-sqlite"&gt;Encrypted fields in an embedded SQLite &lt;/h2&gt;&#10;&lt;p&gt;The first case was a desktop app with an SQLite database that only authorized software could read.&lt;/p&gt;&#10;&lt;p&gt;I considered encrypting the SQLite file and encrypting fields within it, and I chose the latter. That approach is reusable, and you never have to load the whole database into memory to decrypt it or leave it in plaintext for a moment.&lt;/p&gt;&#10;&lt;p&gt;The app already had its database logic, and the goal was to change as little as possible. So I put the encryption inside the ORM, and the rest of the app stayed business as usual.&lt;/p&gt;&#10;&lt;p&gt;The details:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Cipher and key:&lt;/strong&gt; AES-256-CTR with a single key for the whole database. There was no reason for anything more complex. The key is generated on the first run and on the upgrade from the old version.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Storage:&lt;/strong&gt; binary blobs made querying the database in a terminal for debugging a pain. Base64-encoded ciphertext in a text field works better. A new column holds the row&amp;rsquo;s initialization vector (IV), a random array of bytes, also base64-encoded.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Migration:&lt;/strong&gt; it&amp;rsquo;s not only a schema change. Every row needs the fields encrypted for the updated logic to work.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;You can&amp;rsquo;t query against encrypted fields. The ciphertext is a function of the plaintext, the key, and the IV, and the IV is specific to the row. This is a property of the privacy, not a defect. When you need to query encrypted values, the solution is domain-specific: sometimes you can modify the data or compute hashes. Discuss it with the customer in terms of the security objectives they want to achieve. For the same reasons, the data migration is domain-specific too.&lt;/p&gt;&#10;&#10;&lt;h2 id="an-encrypted-payload-in-mongodb-documents"&gt;An encrypted payload in MongoDB documents &lt;/h2&gt;&#10;&lt;p&gt;On another project, the database was MongoDB and had many sensitive fields. I split each document into the sensitive fields and the metadata used for querying. I encrypted the sensitive fields together into a single field. The document also gets the IV and an HMAC over the rest of the document, ciphertext included:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_secure_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_sensitive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;data_secure_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;iv_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cryptjson&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encrypt_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;db&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;created&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;datetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;iv&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;iv_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;ascii&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;data&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;data_secure_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;ascii&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;hmac_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;hmac&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;hmac&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;hmac_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;ascii&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This &lt;code&gt;payload&lt;/code&gt; is what goes into the database. Use a separate key for the HMAC, and verify it before you decrypt: AES-CBC alone doesn&amp;rsquo;t detect tampering.&lt;/p&gt;&#10;&lt;p&gt;For the encryption, I use the &lt;code&gt;cryptography&lt;/code&gt; package from the Python Cryptographic Authority. Encrypting a JSON is no magic: dump it into bytes and encrypt those, here with AES-CBC:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;base64&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;secrets&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;cryptography.hazmat.primitives&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;padding&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;cryptography.hazmat.primitives.ciphers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modes&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encrypt_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pt&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AES&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="n"&gt;modes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CBC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;encryptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;padder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PKCS7&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;padder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;padded_pt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;padder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;padder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;padded_pt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decrypt_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64ct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b64iv&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64iv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AES&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="n"&gt;modes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CBC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;decryptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decryptor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;padded_pt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decryptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;decryptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unpadder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PKCS7&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unpadder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;unpadder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;padded_pt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;unpadder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="a-drop-in-encrypted-text-field"&gt;A drop-in encrypted text field &lt;/h2&gt;&#10;&lt;p&gt;Both solutions were domain-specific. Still, they showed that the use case is common and that encryption in Python is easy to work with. So I wrote cryptbase, a small library that adds an encrypted text field to SQLAlchemy and Django ORM.&lt;/p&gt;&#10;&lt;p&gt;Inside, there is a &lt;code&gt;CryptoContainer&lt;/code&gt;. You create it from a ciphertext and an IV, or from a plaintext, and it gives you access to the ciphertext, the plaintext, and the IV in a unified way:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CryptoContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CryptoContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A separate IV column complicates the migration, and the goal is a drop-in replacement for text fields. The base64 alphabet has no &lt;code&gt;@&lt;/code&gt;, so the field stores both values in one column, with &lt;code&gt;@&lt;/code&gt; as the separator:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;lt;base64 ciphertext&amp;gt;@&amp;lt;base64 IV&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;encryptor&lt;/code&gt; is an object with a simple interface, so there can be one per encryption method, such as &lt;code&gt;AES256CTREncryptor&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&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;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In the end, the ORM gets a simple interface:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# SQLAlchemy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sensitive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EncryptedText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DB_KEY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Django&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sensitive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EncryptedTextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DB_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You work with these fields the same way as with a text field. When the application reads the value, it gets the plaintext from the container. When the value is written to the database, it&amp;rsquo;s encrypted, and the &lt;code&gt;__str__&lt;/code&gt; of the &lt;code&gt;CryptoContainer&lt;/code&gt; joins the base64-encoded ciphertext and IV:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CryptoContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encryptor&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</description></item><item><title>Dynamic DNS for home routers the hacker way</title><link>https://eghuro.cz/posts/dynamic-dns-dnspython-flask/</link><pubDate>Thu, 28 Oct 2021 17:48:15 +0200</pubDate><guid>https://eghuro.cz/posts/dynamic-dns-dnspython-flask/</guid><description>&lt;p&gt;Skip public DDNS services: Python can handle the same job. This post shows how to modify a DNS record in a zone file, upload the zone over SSH, and expose the update as a small web service.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A MikroTik router with a dynamic public IPv4 address&lt;/li&gt;&#10;&lt;li&gt;A name for the router in my own zone, on my primary DNS server&lt;/li&gt;&#10;&lt;li&gt;Python with &lt;code&gt;dnspython&lt;/code&gt;, &lt;code&gt;paramiko&lt;/code&gt;, &lt;code&gt;Flask&lt;/code&gt;, and &lt;code&gt;flask_jwt_extended&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I first looked at dynamic DNS over a standard protocol, but I had to abandon the idea. MikroTik also offers its cloud service for a similar result, but I find it less convenient than a name in my own zone. That led me to a way to modify the zone file with Python.&lt;/p&gt;&#10;&lt;p&gt;The examples cover IPv4 only. Extending them to IPv6 is a small change.&lt;/p&gt;&#10;&#10;&lt;h2 id="modify-the-zone-file"&gt;Modify the zone file &lt;/h2&gt;&#10;&lt;p&gt;The update has three steps: open the zone file, modify the record, and save the file.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&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="n"&gt;newip4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;zones/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;.zone&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;update_dynamic_ip4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&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="n"&gt;newip4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;w&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$ORIGIN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The record update itself:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Load the zone file.&lt;/li&gt;&#10;&lt;li&gt;Find the record.&lt;/li&gt;&#10;&lt;li&gt;Replace it, or stop if the address hasn&amp;rsquo;t changed.&lt;/li&gt;&#10;&lt;li&gt;Update the SOA serial.&lt;/li&gt;&#10;&lt;li&gt;Return the zone as text.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Don&amp;rsquo;t forget the SOA serial. Without the bump, the secondary servers of your zone don&amp;rsquo;t see the change.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;dnspython&lt;/code&gt; does the job, but its API is not my favorite cup of tea:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dns.rdataclass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dns.rdataset&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dns.rdatatype&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;dns.zone&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_dynamic_ip4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zonefile&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="n"&gt;newip4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zonefile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;new_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdataset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdataclass&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdatatype&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newip4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_node&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;oldip4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_rdataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdataclass&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdatatype&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&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;oldip4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;newip4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;NoUpdate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replace_rdataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_rdataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdataclass&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdatatype&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soa_updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;update_soa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soa&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replace_rdataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soa_updated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relativize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I keep the serial as a date (&lt;code&gt;%Y%m%d&lt;/code&gt;) and a two-digit revision. If the serial is from today, the revision increases by one; otherwise, it starts at zero for today. That allows 100 changes a day, plenty for one dynamic address.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_soa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soa&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soa_parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soa&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soa_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;%Y%m&lt;/span&gt;&lt;span class="si"&gt;%d&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;increment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;increment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;):])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;02d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soa_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soa_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34; &amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soa_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soa_parts&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdataset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdataclass&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdatatype&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;soa_new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="upload-the-zone-file-and-reload-the-dns-server"&gt;Upload the zone file and reload the DNS server &lt;/h2&gt;&#10;&lt;p&gt;By hand, you&amp;rsquo;d SSH to the DNS server, replace the file, and reload the server. &lt;code&gt;paramiko&lt;/code&gt; does the same:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;paramiko&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;localfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remotefile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remotehost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remoteport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyfile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;paramiko&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SSHClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load_system_host_keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remotehost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remoteport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;keyfile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open_sftp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ftp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ftp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;localfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remotefile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exec_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;nsd-reload&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# the reload command of your DNS server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The DNS server&amp;rsquo;s host key must already be in your &lt;code&gt;known_hosts&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="expose-it-as-a-rest-api"&gt;Expose it as a REST API &lt;/h2&gt;&#10;&lt;p&gt;The router sends a request, and the app updates the zone. The new address comes from the request itself, and the router identifies itself with a JWT. &lt;code&gt;remotefile&lt;/code&gt;, &lt;code&gt;remotehost&lt;/code&gt;, &lt;code&gt;remoteport&lt;/code&gt;, and &lt;code&gt;keyfile&lt;/code&gt; come from the configuration.&lt;/p&gt;&#10;&lt;p&gt;This example creates a new secret and token each time the app starts, so the router needs the new token after a restart:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;uuid&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;flask_jwt_extended&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JWTManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_jwt_identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jwt_required&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vm"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;JWT_SECRET_KEY&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JWTManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app_context&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;name&amp;#39;&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="s1"&gt;&amp;#39;zone&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;expires_delta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The endpoint:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/api/update/ipv4&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;POST&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@jwt_required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_endpoint&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_jwt_identity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;name&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;zone&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;X-Forwarded-For&amp;#39;&lt;/span&gt; &lt;span class="ow"&gt;in&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;newip&lt;/span&gt; &lt;span class="o"&gt;=&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;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;X-Forwarded-For&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;newip&lt;/span&gt; &lt;span class="o"&gt;=&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;remote_addr&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&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="n"&gt;newip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;NoUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;status&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;unchanged&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;upload_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;zones/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;.zone&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remotefile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remotehost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remoteport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyfile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;status&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;updated&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Most periodic calls find the address unchanged, so &lt;code&gt;NoUpdate&lt;/code&gt; is the common path, not an error. Read &lt;code&gt;X-Forwarded-For&lt;/code&gt; only when the app runs behind your own reverse proxy that sets it; otherwise, any client can put an arbitrary address there. Before deploying, add input validation and handling for the remaining exceptions.&lt;/p&gt;&#10;&lt;p&gt;Once deployed, set up the router to call the endpoint periodically.&lt;/p&gt;&#10;</description></item><item><title>Keep the configuration out of the code</title><link>https://eghuro.cz/posts/cpp-style-classes-to-json/</link><pubDate>Wed, 27 Oct 2021 23:12:58 +0200</pubDate><guid>https://eghuro.cz/posts/cpp-style-classes-to-json/</guid><description>&lt;p&gt;Sometimes it&amp;rsquo;s not a design pattern but a small idea borrowed from another language that gets the job done.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;A factory method picked a Style subclass based on the configuration:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;protected&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// implemented by each subclass&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;Style&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setting1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;&amp;#39;a&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Style1&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setting1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;&amp;#39;b&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Style2&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;Style&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// reads getMap() and getList() from the subclass&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// and applies the style to app&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Over time, this block grew substantially. The number of possible values for &lt;code&gt;setting1&lt;/code&gt; skyrocketed, and other settings came into play.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-idea-from-python"&gt;The idea from Python &lt;/h2&gt;&#10;&lt;p&gt;In Python, I&amp;rsquo;d map each value to a class and instantiate the one I need:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;styles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;a&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Style1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;b&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Style2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;setting1&lt;/span&gt;&lt;span class="p"&gt;]()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This turned out tricky to pull off in C++. It did make me look at what the Style classes actually held: each one only returned a fixed set of properties. In effect, each class was a JSON document.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix-one-json-file-per-configuration"&gt;The fix: one JSON file per configuration &lt;/h2&gt;&#10;&lt;p&gt;Instead of selecting a class, the code can select a JSON file and read the same values from it. I replaced all the Style subclasses with JSON files stored in the resources, one per configuration. &lt;code&gt;install&lt;/code&gt; extracts the maps and lists from the JSON and processes them with the code from the original &lt;code&gt;apply&lt;/code&gt;. Neither the inheritance nor the chain of conditions is needed anymore:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-cpp" data-lang="cpp"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;Style&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// load the maps and lists from the JSON for this configuration&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// process them with the code from the original apply()&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// apply the style to app&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;When subclasses differ only in the values they return, they are configuration, not code.&lt;/p&gt;&#10;</description></item><item><title>SPARQL harvesting traps: a flat OPTIONAL returns the whole store, and rdflib hides the 429</title><link>https://eghuro.cz/posts/harvesting-sparql-endpoints-politely/</link><pubDate>Tue, 14 Sep 2021 11:35:00 +0200</pubDate><guid>https://eghuro.cz/posts/harvesting-sparql-endpoints-politely/</guid><description>&lt;p&gt;Update 2026-11-08: rebuilt every repro on rdflib 6.3.2 and 7.6.0, and on Celery 5.2.7 and 5.7.0.&lt;/p&gt;&#10;&lt;p&gt;I wanted a resource and three hops of its neighbors from a SPARQL endpoint. This query returns every triple in the store:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sparql" data-lang="sparql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CONSTRUCT&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;&amp;lt;http://example.org/root&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;?p1&lt;/span&gt; &lt;span class="nv"&gt;?o1&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;?o1&lt;/span&gt; &lt;span class="nv"&gt;?p2&lt;/span&gt; &lt;span class="nv"&gt;?o2&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;?o2&lt;/span&gt; &lt;span class="nv"&gt;?p3&lt;/span&gt; &lt;span class="nv"&gt;?o3&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nl"&gt;&amp;lt;http://example.org/root&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;?p1&lt;/span&gt; &lt;span class="nv"&gt;?o1&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;OPTIONAL&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;?o1&lt;/span&gt; &lt;span class="nv"&gt;?p2&lt;/span&gt; &lt;span class="nv"&gt;?o2&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;OPTIONAL&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;?o2&lt;/span&gt; &lt;span class="nv"&gt;?p3&lt;/span&gt; &lt;span class="nv"&gt;?o3&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Move the second &lt;code&gt;OPTIONAL&lt;/code&gt; inside the first, and you get the four triples you meant. A comment in my 2021 crawler says &lt;code&gt;nested optionals are a must, otherwise the query will not finish&lt;/code&gt;. This post explains why and covers other ways the crawler could mistreat an endpoint without noticing.&lt;/p&gt;&#10;&lt;p&gt;Everything below comes from rereading that code and rebuilding each case against a local fake endpoint, under Python 3.11. It shows what the code does. I have no production logs that say what it cost anyone.&lt;/p&gt;&#10;&#10;&lt;h2 id="flat-optionals-join-an-unbound-variable-against-everything"&gt;Flat &lt;code&gt;OPTIONAL&lt;/code&gt;s join an unbound variable against everything &lt;/h2&gt;&#10;&lt;p&gt;The test store holds 20,005 triples: a root with a literal label, a four-hop chain, and 20,000 unrelated triples. Times are from one run.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Query shape&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Solutions&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Triples returned&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Oxigraph 0.5.13&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;rdflib 7.6.0&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Flat &lt;code&gt;OPTIONAL&lt;/code&gt;s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;20,006&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;20,005&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;28 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;897 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Nested &lt;code&gt;OPTIONAL&lt;/code&gt;s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;2&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;4&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1.0 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;3.2 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No &lt;code&gt;OPTIONAL&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;3&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0.1 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1.7 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The literal label triggers it. When &lt;code&gt;?o1&lt;/code&gt; is that literal, the first &lt;code&gt;OPTIONAL&lt;/code&gt; can&amp;rsquo;t match, because a literal is never a subject, so &lt;code&gt;?o2&lt;/code&gt; stays unbound. The second &lt;code&gt;OPTIONAL&lt;/code&gt; then has nothing constraining it. &lt;code&gt;?o2 ?p3 ?o3&lt;/code&gt; matches every triple, and the &lt;code&gt;CONSTRUCT&lt;/code&gt; template writes them all out. Most resources have a label, so most lookups hit this.&lt;/p&gt;&#10;&lt;p&gt;Removing the &lt;code&gt;OPTIONAL&lt;/code&gt;s is the wrong repair: the label disappears, as the last row shows.&lt;/p&gt;&#10;&lt;p&gt;I can&amp;rsquo;t rerun the 2021 endpoint, and I only tested these two engines. I believe this is what &amp;ldquo;will not finish&amp;rdquo; meant, since on a store with millions of triples each lookup becomes a full dump.&lt;/p&gt;&#10;&lt;p&gt;A variable you never bind does the same thing. I once committed &lt;code&gt;OPTIONAL&lt;/code&gt;s on &lt;code&gt;?dataset&lt;/code&gt; in a query that bound &lt;code&gt;?ds&lt;/code&gt;, and renamed it 18 minutes later. It&amp;rsquo;s a valid query, so nothing raises an error.&lt;/p&gt;&#10;&lt;p&gt;Nesting fixes the unbound variable and does nothing for hubs. A node with a million outgoing triples still returns them all, so add a &lt;code&gt;LIMIT&lt;/code&gt; if a partial answer is acceptable.&lt;/p&gt;&#10;&#10;&lt;h2 id="send-one-construct-per-named-graph-then-read-your-copy"&gt;Send one &lt;code&gt;CONSTRUCT&lt;/code&gt; per named graph, then read your copy &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Graph(store=SPARQLStore(url))&lt;/code&gt; looks like a local graph, and every &lt;code&gt;value()&lt;/code&gt; call on it is an HTTP request. With 50 datasets and three property reads each:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Approach&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Requests to the endpoint&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Read through the store-backed graph&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;151&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;One &lt;code&gt;CONSTRUCT&lt;/code&gt;, then read &lt;code&gt;.graph&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The graph that &lt;code&gt;.query(...).graph&lt;/code&gt; returns lives in a local &lt;code&gt;Memory&lt;/code&gt; store, so later reads cost nothing. Scope the query with &lt;code&gt;FROM &amp;lt;graph&amp;gt;&lt;/code&gt; and a failure or a retry costs you one named graph.&lt;/p&gt;&#10;&lt;p&gt;Prepared queries belong on that local copy too. A &lt;code&gt;prepareQuery()&lt;/code&gt; object sent through &lt;code&gt;SPARQLStore&lt;/code&gt; dies on a bare &lt;code&gt;AssertionError&lt;/code&gt;, because the store only accepts strings. My git history shows I tried the remote version first.&lt;/p&gt;&#10;&#10;&lt;h2 id="sparqlstore-hides-what-the-endpoint-tells-you"&gt;&lt;code&gt;SPARQLStore&lt;/code&gt; hides what the endpoint tells you &lt;/h2&gt;&#10;&lt;p&gt;Both rdflib versions behave the same:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Input&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Result&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;session=requests.Session()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Accepted, never used&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No &lt;code&gt;headers=&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;User-Agent: Python-urllib/3.11&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;timeout=1&lt;/code&gt;, server hangs 4 s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Waits the full 4 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;429 with &lt;code&gt;Retry-After&lt;/code&gt;, default GET&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ValueError: You did something wrong formulating either the URI or your SPARQL query&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;429, &lt;code&gt;method=&amp;quot;POST&amp;quot;&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Returns the tuple &lt;code&gt;(429, 'HTTP Error 429: Too Many Requests', None)&lt;/code&gt;, then &lt;code&gt;AttributeError&lt;/code&gt; on &lt;code&gt;.graph&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The 7.6.0 connector docstring says extra keyword arguments &amp;ldquo;can be used to set up timeouts etc.&amp;rdquo;, but the code reads only &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;headers&lt;/code&gt; from them. &lt;code&gt;socket.setdefaulttimeout()&lt;/code&gt; does work for the whole process.&lt;/p&gt;&#10;&lt;p&gt;My extraction query passed &lt;code&gt;session=&lt;/code&gt; and no &lt;code&gt;headers=&lt;/code&gt;, and logged &lt;code&gt;ValueError&lt;/code&gt; at debug level. An endpoint asking for a pause would have produced a debug line and a skipped graph, sent by an anonymous-looking client.&lt;/p&gt;&#10;&lt;p&gt;For &lt;code&gt;CONSTRUCT&lt;/code&gt;, skip the store and use the &lt;a&#10; href="https://www.w3.org/TR/sparql11-protocol"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;SPARQL protocol&lt;/a&gt;&#10; directly:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;requests&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;rdflib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Graph&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;query&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;application/n-triples, text/turtle;q=0.9, application/rdf+xml;q=0.8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Retry-After&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# may also be an HTTP date&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;Backoff&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="n"&gt;value&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;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;media_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="s2"&gt;&amp;#34;Content-Type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;;&amp;#34;&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;session&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="s2"&gt;&amp;#34;User-Agent&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;my-harvester/1.0 (+https://example.org/about)&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Against the fake endpoint, this returned all 200 triples and raised &lt;code&gt;Backoff(120)&lt;/code&gt; on the 429. The same &lt;code&gt;post()&lt;/code&gt; with a 1-second read timeout raised &lt;code&gt;ReadTimeout&lt;/code&gt; on the hanging server. Stripping the media type parameters matters for the reason in my &lt;a&#10; href="https://eghuro.cz/posts/linked-data-in-python-field-notes"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;rdflib field notes&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="celery-chunks-runs-your-task-as-a-plain-function"&gt;Celery &lt;code&gt;chunks&lt;/code&gt; runs your task as a plain function &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;task.chunks(args, n)&lt;/code&gt; takes a &lt;a&#10; href="https://docs.celeryq.dev/en/stable/userguide/canvas.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;chunk size&lt;/a&gt;&#10;, and my docstring claimed it was several tasks. Ten graphs with &lt;code&gt;n=4&lt;/code&gt; make three tasks. Items inside a chunk run in sequence, while the chunks run in parallel, so &lt;code&gt;chunks&lt;/code&gt; puts no cap on concurrency against the endpoint.&lt;/p&gt;&#10;&lt;p&gt;The worse part is retries. I raised &lt;code&gt;self.retry()&lt;/code&gt; on the second of ten graphs:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Canvas&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Outcome&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;task.chunks(zip(graphs), 4)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Chunk ends in &lt;code&gt;FAILURE&lt;/code&gt;, no retry, the next two graphs never run&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;group(task.s(g) for g in graphs)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Second graph retried once, all ten finish&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Each chunk is a &lt;code&gt;celery.starmap&lt;/code&gt; task that executes &lt;code&gt;task(*item)&lt;/code&gt;. A direct call leaves &lt;code&gt;request.called_directly&lt;/code&gt; true, and in that case &lt;code&gt;retry()&lt;/code&gt; re-raises the exception and schedules nothing. Celery 5.2.7 and 5.7.0 agree.&lt;/p&gt;&#10;&lt;p&gt;Use a &lt;code&gt;group&lt;/code&gt;, and give each endpoint its own queue with one worker slot:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inspect_graph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;graph_iri&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph_iri&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;Backoff&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;countdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspect_graph&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&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;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graphs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;endpoint-a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;celery -A harvester worker -Q endpoint-a --concurrency&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="a-dereference-fallback-is-traffic-too"&gt;A dereference fallback is traffic too &lt;/h2&gt;&#10;&lt;p&gt;When fetching an IRI failed, my crawler asked the dataset&amp;rsquo;s own endpoint for that resource. The idea holds up, since plenty of linked data has IRIs that don&amp;rsquo;t resolve while the triples sit in an endpoint.&lt;/p&gt;&#10;&lt;p&gt;Mine fell back on every failure, each one costing a three-hop &lt;code&gt;CONSTRUCT&lt;/code&gt;. That included a &lt;code&gt;robots.txt&lt;/code&gt; disallow on the resource, with only the endpoint&amp;rsquo;s own &lt;code&gt;robots.txt&lt;/code&gt; still checked. Send fallbacks through the same queue, and stop when the site said no.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-iris-against-the-sparql-grammar"&gt;Check IRIs against the SPARQL grammar &lt;/h2&gt;&#10;&lt;p&gt;Crawled IRIs ended up in &lt;code&gt;&amp;lt;{iri}&amp;gt;&lt;/code&gt; inside query strings. &lt;code&gt;rfc3987.match()&lt;/code&gt; rejects &lt;code&gt;&amp;gt;&lt;/code&gt; and quotes, and accepts &lt;code&gt;http://example.org/a\n&lt;/code&gt;, because its pattern ends in &lt;code&gt;$&lt;/code&gt;, which matches before a trailing newline. &lt;code&gt;URIRef(x).n3()&lt;/code&gt; passes that newline too, along with &lt;code&gt;\r&lt;/code&gt; and a newline mid-string, since rdflib&amp;rsquo;s list of invalid characters has no control characters.&lt;/p&gt;&#10;&lt;p&gt;That newline can&amp;rsquo;t close the IRI, so this isn&amp;rsquo;t an injection. You get a malformed query and a wasted request. I added &lt;code&gt;.strip()&lt;/code&gt; at the time, on one of the code paths. The &lt;a&#10; href="https://www.w3.org/TR/sparql11-query/#rIRIREF"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;IRIREF&lt;/code&gt; production&lt;/a&gt;&#10; is short enough to check directly:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;FORBIDDEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;lt;&amp;gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="s1"&gt;|^`&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&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="nb"&gt;chr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x21&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;iri_ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;FORBIDDEN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;not safe in a SPARQL IRI: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;lt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Politeness here came down to two things my code got wrong: how much it asked for, and whether it could hear the answer.&lt;/p&gt;&#10;&lt;p&gt;Try this on your own harvester: load one unrelated triple into a test store, run each &lt;code&gt;CONSTRUCT&lt;/code&gt;, and fail the test if that triple comes back.&lt;/p&gt;&#10;</description></item><item><title>Flask @cached decorator traps: POST gets the cached GET, no-store is stored, and X-Last-Modified: None</title><link>https://eghuro.cz/posts/flask-cache-decorator-review/</link><pubDate>Fri, 19 Oct 2018 21:26:00 +0200</pubDate><guid>https://eghuro.cz/posts/flask-cache-decorator-review/</guid><description>&lt;p&gt;Update 2026-11-08: I rebuilt every repro on Flask 2.2.3 with Werkzeug 2.2.3 and Flask-Caching 2.0.2, and on Flask 3.1.3 with Werkzeug 3.1.9 and Flask-Caching 2.5.1, under Python 3.11.&lt;/p&gt;&#10;&lt;p&gt;A Flask view decorator I kept for years failed in a way that only showed up when a POST got the body of an earlier GET. The view never ran, and the client got a 200:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /item 200 &amp;#34;GET ran&amp;#34; X-Cache: MISS&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /item 200 &amp;#34;GET ran&amp;#34; X-Cache: HIT from Server&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The cache key was &lt;code&gt;request.full_path&lt;/code&gt;, so any two requests with the same URL shared an entry, regardless of method, user, or &lt;code&gt;Accept&lt;/code&gt; header. To fix this, let only anonymous GET and HEAD requests touch the shared entry, build the key from the response inputs, and leave the HTTP headers to Werkzeug.&lt;/p&gt;&#10;&lt;p&gt;The decorator entered a 2018 project with a docstring pointing at a &lt;a&#10; href="https://codereview.stackexchange.com/q/147038"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Code Review question&lt;/a&gt;&#10; and &lt;a&#10; href="https://jakearchibald.com/2016/caching-best-practices"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Jake Archibald&amp;rsquo;s caching article&lt;/a&gt;&#10;. I rebuilt each case from rereading it. My routes were all GET-only and &lt;code&gt;public&lt;/code&gt;, so the POST and &lt;code&gt;private&lt;/code&gt; cases never fired, and I have no logs showing what the others cost anyone.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-decorator-with-one-shared-entry-per-representation"&gt;A decorator with one shared entry per representation &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;functools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;wraps&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlencode&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;make_response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;flask_caching&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Cache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;cache&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;application/json&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;server_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;Server-cache a public view. Declare the query parameters it reads.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decorator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@wraps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;GET&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;HEAD&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;or&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;authorization&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;or&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;cookies&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&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;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getlist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;doseq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;media_type&lt;/span&gt; &lt;span class="o"&gt;=&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;accept_mimetypes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;best_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offers&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;view/&lt;/span&gt;&lt;span class="si"&gt;{&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;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;#&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;make_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_etag&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_control&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_control&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;no_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&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="n"&gt;response&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;server_timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;make_conditional&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decorator&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Both decorators show the same probes, identical on both version sets:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Probe&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Old decorator&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Fixed decorator&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;POST after GET&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Cached GET body, view skipped&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;View runs&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Same URL, cookies for two users&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Second user sees the first user&amp;rsquo;s page&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each sees their own&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Anonymous request after a logged-in one&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;200 with the logged-in body&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;401&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;JSON, then &lt;code&gt;Accept: text/turtle&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;JSON for both&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Turtle for the second&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;100 requests with &lt;code&gt;Cache-Control: no-cache&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;100 view runs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0 after the first&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;100 requests with a junk &lt;code&gt;?x=N&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;100 view runs, 100 entries&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0 after the first, 1 entry&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;If-None-Match&lt;/code&gt; with the &lt;code&gt;W/&lt;/code&gt; form of the tag&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;200&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;304&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;304 headers&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No &lt;code&gt;ETag&lt;/code&gt;, &lt;code&gt;X-Last-Modified: None&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ETag&lt;/code&gt;, &lt;code&gt;Cache-Control&lt;/code&gt;, &lt;code&gt;Vary&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Two caveats. Bypassing any cookie is blunt, since one analytics cookie turns the cache off for that browser, so narrow it to your session cookie. And &lt;code&gt;params&lt;/code&gt; must list every argument the view reads, or two different URLs collide again. For per-user caching, put the user ID in the key and send &lt;code&gt;private&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="no-store-and-private-dont-bind-your-own-cache"&gt;&lt;code&gt;no-store&lt;/code&gt; and &lt;code&gt;private&lt;/code&gt; don&amp;rsquo;t bind your own cache &lt;/h2&gt;&#10;&lt;p&gt;The bare &lt;code&gt;@cached()&lt;/code&gt; sent &lt;code&gt;Cache-Control: no-store&lt;/code&gt; and still called &lt;code&gt;cache.set&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /nostore 200 Cache-Control: no-store X-Cache: MISS&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /nostore 200 Cache-Control: no-store X-Cache: HIT from Server&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.5"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;no-store&lt;/code&gt;&lt;/a&gt;&#10; and &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.7"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;private&lt;/code&gt;&lt;/a&gt;&#10; are instructions to the browser and to proxies. An entry your application writes to Redis is outside their reach. The arguments &lt;code&gt;cacheable=False&lt;/code&gt; and &lt;code&gt;client_only=True&lt;/code&gt; read like server switches and only picked a header. With &lt;code&gt;private&lt;/code&gt;, the second user received &lt;code&gt;hello alice&lt;/code&gt; from the shared entry.&lt;/p&gt;&#10;&lt;p&gt;Decorator order makes it worse. With &lt;code&gt;@cached&lt;/code&gt; above a login check, a hit never reaches the check: after one logged-in request, an anonymous client got a 200 with &lt;code&gt;account of alice&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-method-guard-covered-writes-only"&gt;The method guard covered writes only &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;request.method in [&amp;quot;GET&amp;quot;, &amp;quot;HEAD&amp;quot;]&lt;/code&gt; protected &lt;code&gt;cache.set&lt;/code&gt;. The &lt;code&gt;cache.get&lt;/code&gt; above it had no guard. A POST on a cold cache runs the view, so a test that only sends POST passes.&lt;/p&gt;&#10;&lt;p&gt;Flask-Caching&amp;rsquo;s &lt;code&gt;@cache.cached(timeout=60)&lt;/code&gt; did the same on a GET and POST route, in both versions. Add &lt;code&gt;unless=lambda: request.method != &amp;quot;GET&amp;quot;&lt;/code&gt; there.&lt;/p&gt;&#10;&#10;&lt;h2 id="any-client-could-pick-the-miss"&gt;Any client could pick the miss &lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&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;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Cache-Control&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;no-cache&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The comment says &amp;ldquo;respect the hard-refresh&amp;rdquo;. It also hands every client a way to run the expensive view on demand: 100 requests, 100 runs. The comparison is exact, so 100 requests with &lt;code&gt;no-cache, no-store&lt;/code&gt; or &lt;code&gt;max-age=0&lt;/code&gt; ran the view once. A junk query parameter does the same and adds an entry per request.&lt;/p&gt;&#10;&lt;p&gt;Your application&amp;rsquo;s cache is part of the origin, and no RFC asks it to obey a client&amp;rsquo;s request header. Invalidate from the server with &lt;code&gt;cache.delete&lt;/code&gt;. The price: a hard refresh shows the cached body until &lt;code&gt;server_timeout&lt;/code&gt; passes.&lt;/p&gt;&#10;&#10;&lt;h2 id="one-typo-one-bytes-object"&gt;One typo, one bytes object &lt;/h2&gt;&#10;&lt;p&gt;The 304 branch read a header that was never written:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;X-Last-Modified&amp;#34;&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;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;X-Last-Modified&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;X-LastModified&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Werkzeug turns &lt;code&gt;None&lt;/code&gt; into a string, so every 304 carried &lt;code&gt;X-Last-Modified: None&lt;/code&gt; and no error. It also lacked the &lt;code&gt;ETag&lt;/code&gt; that &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9110#section-15.4.5"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RFC 9110&lt;/a&gt;&#10; requires on a 304.&lt;/p&gt;&#10;&lt;p&gt;The tag was &lt;code&gt;binascii.hexlify(os.urandom(4))&lt;/code&gt;, which returns bytes:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Werkzeug&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;code&gt;ETag&lt;/code&gt; value&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2.2.3 to 2.3.8&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;a79d7c42&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;3.0.0 and 3.1.9&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;b'77155122'&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Neither has the &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;double quotes&lt;/a&gt;&#10; that the grammar requires. Both round-trip through &lt;code&gt;If-None-Match&lt;/code&gt; unchanged, so the change in Werkzeug 3.0 raises no error. The comparison was a literal &lt;code&gt;==&lt;/code&gt;, where &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;If-None-Match calls for weak comparison&lt;/a&gt;&#10;, and a random tag changes every time the entry refills even if the body doesn&amp;rsquo;t. &lt;code&gt;add_etag()&lt;/code&gt; hashes the body and &lt;code&gt;make_conditional()&lt;/code&gt; does the comparison.&lt;/p&gt;&#10;&#10;&lt;h2 id="no-cache-means-revalidate"&gt;&lt;code&gt;no-cache&lt;/code&gt; means revalidate &lt;/h2&gt;&#10;&lt;p&gt;The old comments had this right: &amp;ldquo;&lt;code&gt;no-cache&lt;/code&gt; doesn&amp;rsquo;t mean &amp;lsquo;don&amp;rsquo;t cache&amp;rsquo;&amp;rdquo; and &amp;ldquo;&lt;code&gt;must-revalidate&lt;/code&gt; doesn&amp;rsquo;t mean &amp;lsquo;must revalidate&amp;rsquo;&amp;rdquo;.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Directive&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Stored?&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Reused without asking the server?&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;no-store&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;no-cache&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Yes&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;must-revalidate, max-age=900&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Yes&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;For 900 s, then it must revalidate&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Then every call site passed &lt;code&gt;must_revalidate=True, client_timeout=900&lt;/code&gt;, which sent &lt;code&gt;no-cache, public, max-age=900&lt;/code&gt;. An unqualified &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;&lt;code&gt;no-cache&lt;/code&gt;&lt;/a&gt;&#10; forces validation on every reuse, so the 900 seconds bought nothing. I had the explanation in the file and still passed both.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A server-side entry goes to everyone whose request maps to its key, so list what the response depends on and put each item in the key or refuse to cache.&lt;/p&gt;&#10;&lt;p&gt;Try it on your own cached views: GET a URL, POST to it, then GET it with another user&amp;rsquo;s cookie, and fail the test if any two bodies match.&lt;/p&gt;&#10;</description></item></channel></rss>